Feature: Toast Notification System (#25)

* Add react-hot-toaster to dependencies

* Enable TailwindCSS 'jit' mode

* Add Toast component

* Add Toaster context for react-hot-toast

* Add toast notification for queries, form validation fix

* Add new animations for Toast component

* fix: nickserv account validation

Co-authored-by: Ludvig Lundgren <hello@ludviglundgren.se>
This commit is contained in:
smallobject 2021-08-31 19:53:42 +03:00 committed by GitHub
parent 00f956870b
commit 11fcf1ead9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 195 additions and 14 deletions

View file

@ -4,6 +4,7 @@ import {useEffect, useState} from "react";
import { Fragment } from "react";
import {Redirect} from "react-router-dom";
import APIClient from "../api/APIClient";
import { Toaster } from "react-hot-toast";
export default function Layout({auth=false, authFallback="/login", children}: any) {
const [loggedIn, setLoggedIn] = useRecoilState(isLoggedIn);
@ -28,6 +29,7 @@ export default function Layout({auth=false, authFallback="/login", children}: an
<Fragment>
{auth && !loggedIn ? <Redirect to={authFallback} /> : (
<Fragment>
<Toaster position="top-right" />
{children}
</Fragment>
)}

View file

@ -0,0 +1,52 @@
import { FC } from 'react'
import { XIcon, CheckCircleIcon, ExclamationIcon, ExclamationCircleIcon } from '@heroicons/react/solid'
import { toast } from 'react-hot-toast'
type Props = {
type: 'error' | 'success' | 'warning'
body?: string
t?: any;
}
const Toast: FC<Props> = ({
type,
body,
t
}) => {
return (
<div className={`${
t.visible ? 'animate-enter' : 'animate-leave'
} max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden transition-all`}>
<div className="p-4">
<div className="flex items-start">
<div className="flex-shrink-0">
{type === 'success' && <CheckCircleIcon className="h-6 w-6 text-green-400" aria-hidden="true" />}
{type === 'error' && <ExclamationCircleIcon className="h-6 w-6 text-red-400" aria-hidden="true" />}
{type === 'warning' && <ExclamationIcon className="h-6 w-6 text-yellow-400" aria-hidden="true" />}
</div>
<div className="ml-3 w-0 flex-1 pt-0.5">
<p className="text-sm font-medium text-gray-900">
{type === 'success' && "Success"}
{type === 'error' && "Error"}
{type === 'warning' && "Warning"}
</p>
<p className="mt-1 text-sm text-gray-500">{body}</p>
</div>
<div className="ml-4 flex-shrink-0 flex">
<button
className="bg-white rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
onClick={() => {
toast.dismiss(t.id)
}}
>
<span className="sr-only">Close</span>
<XIcon className="h-5 w-5" aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
)
}
export default Toast;

View file

@ -17,6 +17,9 @@ import {
RadioFieldsetWide,
} from "../../components/inputs/wide";
import { toast } from 'react-hot-toast'
import Toast from '../../components/notifications/Toast';
interface DownloadClientSelectProps {
name: string;
clients: DownloadClient[];
@ -135,6 +138,8 @@ function FilterActionAddForm({ filter, isOpen, toggle, clients }: props) {
{
onSuccess: () => {
queryClient.invalidateQueries(["filter", filter.id]);
toast.custom((t) => <Toast type="success" body="Action was added" t={t} />)
sleep(500).then(() => toggle());
},
}

View file

@ -17,6 +17,10 @@ import {
} from "../../components/inputs/wide";
import { DownloadClientSelect } from "./FilterActionAddForm";
import { toast } from 'react-hot-toast'
import Toast from '../../components/notifications/Toast';
interface props {
filter: Filter;
isOpen: boolean;
@ -38,6 +42,8 @@ function FilterActionUpdateForm({
onSuccess: () => {
// console.log("add action");
queryClient.invalidateQueries(["filter", filter.id]);
toast.custom((t) => <Toast type="success" body={`${filter.name} was updated successfully`} t={t} />)
sleep(1500);
toggle();

View file

@ -8,12 +8,18 @@ import {Field, Form} from "react-final-form";
import DEBUG from "../../components/debug";
import APIClient from "../../api/APIClient";
import { toast } from 'react-hot-toast'
import Toast from '../../components/notifications/Toast';
const required = (value: any) => (value ? undefined : 'Required')
function FilterAddForm({isOpen, toggle}: any) {
const mutation = useMutation((filter: Filter) => APIClient.filters.create(filter), {
onSuccess: () => {
queryClient.invalidateQueries('filter');
toast.custom((t) => <Toast type="success" body="Filter was added" t={t} />)
toggle()
}
})

View file

@ -11,7 +11,8 @@ import { queryClient } from "../../App";
import { SwitchGroup, TextFieldWide } from "../../components/inputs";
import APIClient from "../../api/APIClient";
import { NumberFieldWide, PasswordFieldWide } from "../../components/inputs/wide";
import { toast } from 'react-hot-toast'
import Toast from '../../components/notifications/Toast';
interface props {
isOpen: boolean;
toggle: any;
@ -28,9 +29,12 @@ function IndexerAddForm({ isOpen, toggle }: props) {
const mutation = useMutation((indexer: Indexer) => APIClient.indexers.create(indexer), {
onSuccess: () => {
queryClient.invalidateQueries(['indexer']);
toast.custom((t) => <Toast type="success" body="Indexer was added" t={t} />)
sleep(1500)
toggle()
},
onError: () => {
toast.custom((t) => <Toast type="error" body="Indexer could not be added" t={t} />)
}
})

View file

@ -12,6 +12,9 @@ import APIClient from "../../api/APIClient";
import { queryClient } from "../../App";
import { PasswordFieldWide } from "../../components/inputs/wide";
import { toast } from 'react-hot-toast'
import Toast from '../../components/notifications/Toast';
interface props {
isOpen: boolean;
toggle: any;
@ -24,6 +27,7 @@ function IndexerUpdateForm({ isOpen, toggle, indexer }: props) {
const mutation = useMutation((indexer: Indexer) => APIClient.indexers.update(indexer), {
onSuccess: () => {
queryClient.invalidateQueries(['indexer']);
toast.custom((t) => <Toast type="success" body={`${indexer.name} was updated successfully`} t={t}/>)
sleep(1500)
toggle()
@ -33,6 +37,7 @@ function IndexerUpdateForm({ isOpen, toggle, indexer }: props) {
const deleteMutation = useMutation((id: number) => APIClient.indexers.delete(id), {
onSuccess: () => {
queryClient.invalidateQueries(['indexer']);
toast.custom((t) => <Toast type="success" body={`${indexer.name} was deleted.`} t={t}/>)
}
})

View file

@ -14,17 +14,31 @@ import {classNames} from "../../styles/utils";
import APIClient from "../../api/APIClient";
import { NumberFieldWide, PasswordFieldWide } from "../../components/inputs/wide";
import { toast } from 'react-hot-toast';
import Toast from '../../components/notifications/Toast';
type FormValues = {
name: string
server: string
nickserv: {
account: string
}
port: number
}
function IrcNetworkAddForm({isOpen, toggle}: any) {
const mutation = useMutation((network: Network) => APIClient.irc.createNetwork(network), {
onSuccess: data => {
onSuccess: (data) => {
queryClient.invalidateQueries(['networks']);
toast.custom((t) => <Toast type="success" body="IRC Network added" t={t} />)
toggle()
}
},
onError: () => {
toast.custom((t) => <Toast type="error" body="IRC Network could not be added" t={t}/>)
},
})
const onSubmit = (data: any) => {
console.log(data)
// easy way to split textarea lines into array of strings for each newline.
// parse on the field didn't really work.
let cmds = data.connect_commands && data.connect_commands.length > 0 ? data.connect_commands.replace(/\r\n/g,"\n").split("\n") : [];
@ -35,22 +49,34 @@ function IrcNetworkAddForm({isOpen, toggle}: any) {
};
const validate = (values: any) => {
const errors = {} as any;
const errors = {
nickserv: {
account: null,
}
} as any;
if (!values.name) {
errors.name = "Required";
}
if (!values.port) {
errors.port = "Required";
}
if (!values.server) {
errors.server = "Required";
}
if(!values.nickserv?.account) {
errors.nickserv.account = "Required";
}
return errors;
}
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" static className="fixed inset-0 overflow-hidden" open={isOpen} onClose={toggle}>
<Dialog as="div" static className="fixed inset-0 overflow-hidden transition-all" open={isOpen} onClose={toggle}>
<div className="absolute inset-0 overflow-hidden">
<Dialog.Overlay className="absolute inset-0"/>
@ -73,6 +99,9 @@ function IrcNetworkAddForm({isOpen, toggle}: any) {
server: "",
tls: false,
pass: "",
nickserv: {
account: ""
}
}}
mutators={{
...arrayMutators
@ -119,7 +148,7 @@ function IrcNetworkAddForm({isOpen, toggle}: any) {
<div>
<TextFieldWide name="server" label="Server" placeholder="Address: Eg irc.server.net" required={true} />
<NumberFieldWide name="port" label="Port" required={true} />
<NumberFieldWide name="port" label="Port" placeholder="Eg 6667" required={true} />
<div className="py-6 px-6 space-y-6 sm:py-0 sm:space-y-0 sm:divide-y sm:divide-gray-200">
<SwitchGroup name="tls" label="TLS"/>
@ -127,7 +156,7 @@ function IrcNetworkAddForm({isOpen, toggle}: any) {
<PasswordFieldWide name="pass" label="Password" help="Network password" />
<TextFieldWide name="nickserv.account" label="NickServ Account" required={true} />
<TextFieldWide name="nickserv.account" label="NickServ Account" placeholder="NickServ Account" required={true} />
<PasswordFieldWide name="nickserv.password" label="NickServ Password" />
<PasswordFieldWide name="invite_command" label="Invite command" />

View file

@ -16,12 +16,16 @@ import { DeleteModal } from "../../components/modals";
import APIClient from "../../api/APIClient";
import { NumberFieldWide, PasswordFieldWide } from "../../components/inputs/wide";
import { toast } from 'react-hot-toast';
import Toast from '../../components/notifications/Toast';
function IrcNetworkUpdateForm({ isOpen, toggle, network }: any) {
const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false)
const mutation = useMutation((network: Network) => APIClient.irc.updateNetwork(network), {
onSuccess: () => {
queryClient.invalidateQueries(['networks']);
toast.custom((t) => <Toast type="success" body={`${network.name} was updated successfully`} t={t} />)
toggle()
}
})
@ -29,6 +33,8 @@ function IrcNetworkUpdateForm({ isOpen, toggle, network }: any) {
const deleteMutation = useMutation((id: number) => APIClient.irc.deleteNetwork(id), {
onSuccess: () => {
queryClient.invalidateQueries(['networks']);
toast.custom((t) => <Toast type="success" body={`${network.name} was deleted.`} t={t} />)
toggle()
}
})
@ -52,7 +58,11 @@ function IrcNetworkUpdateForm({ isOpen, toggle, network }: any) {
};
const validate = (values: any) => {
const errors = {} as any;
const errors = {
nickserv: {
account: null,
}
} as any;
if (!values.name) {
errors.name = "Required";
@ -66,7 +76,7 @@ function IrcNetworkUpdateForm({ isOpen, toggle, network }: any) {
errors.port = "Required";
}
if (!values.nickserv.account) {
if(!values.nickserv?.account) {
errors.nickserv.account = "Required";
}

View file

@ -17,6 +17,9 @@ import { DownloadClientTypeOptions } from "../../../domain/constants";
import { RadioFieldsetWide } from "../../../components/inputs/wide";
import { componentMap } from "./shared";
import { toast } from 'react-hot-toast'
import Toast from '../../../components/notifications/Toast';
function DownloadClientAddForm({ isOpen, toggle }: any) {
const [isTesting, setIsTesting] = useState(false);
const [isSuccessfulTest, setIsSuccessfulTest] = useState(false);
@ -27,8 +30,13 @@ function DownloadClientAddForm({ isOpen, toggle }: any) {
{
onSuccess: () => {
queryClient.invalidateQueries(["downloadClients"]);
toast.custom((t) => <Toast type="success" body="Client was added" t={t} />)
toggle();
},
onError: () => {
toast.custom((t) => <Toast type="error" body="Client could not be added" t={t} />)
}
}
);
@ -53,6 +61,7 @@ function DownloadClientAddForm({ isOpen, toggle }: any) {
});
},
onError: (error) => {
console.log('not added')
setIsTesting(false);
setIsErrorTest(true);
sleep(2500).then(() => {

View file

@ -16,6 +16,10 @@ import { componentMap } from "./shared";
import { RadioFieldsetWide } from "../../../components/inputs/wide";
import { DeleteModal } from "../../../components/modals";
import { toast } from 'react-hot-toast'
import Toast from '../../../components/notifications/Toast';
function DownloadClientUpdateForm({ client, isOpen, toggle }: any) {
const [isTesting, setIsTesting] = useState(false);
const [isSuccessfulTest, setIsSuccessfulTest] = useState(false);
@ -27,7 +31,7 @@ function DownloadClientUpdateForm({ client, isOpen, toggle }: any) {
{
onSuccess: () => {
queryClient.invalidateQueries(["downloadClients"]);
toast.custom((t) => <Toast type="success" body={`${client.name} was updated successfully`} t={t} />)
toggle();
},
}
@ -38,6 +42,7 @@ function DownloadClientUpdateForm({ client, isOpen, toggle }: any) {
{
onSuccess: () => {
queryClient.invalidateQueries();
toast.custom((t) => <Toast type="success" body={`${client.name} was deleted.`} t={t}/>)
toggleDeleteModal();
},
}

View file

@ -15,3 +15,35 @@ code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
@keyframes enter {
0% {
transform: scale(.9);
opacity: 0
}
to {
transform: scale(1);
opacity: 1
}
}
.animate-enter {
animation: enter .2s ease-out
}
@keyframes leave {
0% {
transform: scale(1);
opacity: 1
}
to {
transform: scale(.9);
opacity: 0
}
}
.animate-leave {
animation: leave .15s ease-in forwards
}

View file

@ -29,6 +29,10 @@ import { FilterAddForm, FilterActionAddForm} from "../forms";
import Select from "react-select";
import APIClient from "../api/APIClient";
import { toast } from 'react-hot-toast'
import Toast from '../components/notifications/Toast';
const tabs = [
{name: 'General', href: '', current: true},
// { name: 'TV', href: 'tv', current: false },
@ -443,6 +447,8 @@ function FilterTabGeneral({filter}: FilterTabGeneralProps) {
const updateMutation = useMutation((filter: Filter) => APIClient.filters.update(filter), {
onSuccess: () => {
// queryClient.setQueryData(['filter', filter.id], data)
toast.custom((t) => <Toast type="success" body={`${filter.name} was updated successfully`} t={t} />)
queryClient.invalidateQueries(["filter",filter.id]);
}
})
@ -451,6 +457,8 @@ function FilterTabGeneral({filter}: FilterTabGeneralProps) {
onSuccess: () => {
// invalidate filters
queryClient.invalidateQueries("filter");
toast.custom((t) => <Toast type="success" body={`${filter.name} was deleted`} t={t} />)
// redirect
history.push("/filters")
}
@ -568,6 +576,7 @@ function FilterTabMoviesTvNew2({filter}: FilterTabGeneralProps) {
const updateMutation = useMutation((filter: Filter) => APIClient.filters.update(filter), {
onSuccess: () => {
// queryClient.setQueryData(['filter', filter.id], data)
toast.custom((t) => <Toast type="success" body={`${filter.name} was updated successfully`} t={t} />)
queryClient.invalidateQueries(["filter",filter.id]);
}
})
@ -575,6 +584,7 @@ function FilterTabMoviesTvNew2({filter}: FilterTabGeneralProps) {
const deleteMutation = useMutation((id: number) => APIClient.filters.delete(id), {
onSuccess: () => {
// invalidate filters
toast.custom((t) => <Toast type="success" body={`${filter.name} was deleted`} t={t} />)
queryClient.invalidateQueries("filter");
// redirect
history.push("/filters")
@ -677,6 +687,8 @@ function FilterTabAdvanced({filter}: FilterTabGeneralProps) {
const updateMutation = useMutation((filter: Filter) => APIClient.filters.update(filter), {
onSuccess: () => {
// queryClient.setQueryData(['filter', filter.id], data)
toast.custom((t) => <Toast type="success" body={`${filter.name} was updated successfully`} t={t} />)
queryClient.invalidateQueries(["filter",filter.id]);
}
})
@ -685,6 +697,8 @@ function FilterTabAdvanced({filter}: FilterTabGeneralProps) {
onSuccess: () => {
// invalidate filters
queryClient.invalidateQueries("filter");
toast.custom((t) => <Toast type="success" body={`${filter.name} was deleted`} t={t} />)
// redirect
history.push("/filters")
}