dev: email notifications (#3421)

* dev: create email notification preference model

* dev: intiate models

* dev: user notification preferences

* dev: create notification logs for the user.

* dev: email notification stacking and sending logic

* feat: email notification preference settings page.

* dev: delete subscribers

* dev: issue update ui implementation in email notification

* chore: integrate email notification endpoint.

* chore: remove toggle switch.

* chore: added labels part

* fix: refactored base design with tables

* dev: email notification templates

* dev: template updates

* dev: update models

* dev: update template for labels and new migrations

* fix: profile settings preference sidebar.

* dev: update preference endpoints

* dev: update the schedule to 5 minutes

* dev: update template with priority data

* dev: update templates

* chore: enable `issue subscribe` button for all users.

* chore: notification handling for external api

* dev: update origin request

---------

Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
Co-authored-by: LAKHAN BAHETI <lakhanbaheti9@gmail.com>
Co-authored-by: Ramesh Kumar Chandra <rameshkumar2299@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
Nikhil 2024-01-23 17:49:22 +05:30 committed by GitHub
parent c1e1b81b99
commit f27efb80e1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 2482 additions and 314 deletions

View file

@ -154,12 +154,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<div className="flex flex-wrap items-center gap-2">
{currentUser && !is_archived && (fieldsToShow.includes("all") || fieldsToShow.includes("subscribe")) && (
<IssueSubscription
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
currentUserId={currentUser?.id}
/>
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
)}
{(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && (

View file

@ -11,14 +11,12 @@ export type TIssueSubscription = {
workspaceSlug: string;
projectId: string;
issueId: string;
currentUserId: string;
};
export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
const { workspaceSlug, projectId, issueId, currentUserId } = props;
const { workspaceSlug, projectId, issueId } = props;
// hooks
const {
issue: { getIssueById },
subscription: { getSubscriptionByIssueId },
createSubscription,
removeSubscription,
@ -27,7 +25,6 @@ export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
// state
const [loading, setLoading] = useState(false);
const issue = getIssueById(issueId);
const subscription = getSubscriptionByIssueId(issueId);
const handleSubscription = async () => {
@ -51,8 +48,6 @@ export const IssueSubscription: FC<TIssueSubscription> = observer((props) => {
}
};
if (issue?.created_by === currentUserId || issue?.assignee_ids?.includes(currentUserId)) return <></>;
return (
<div>
<Button

View file

@ -188,12 +188,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
<IssueUpdateStatus isSubmitting={isSubmitting} />
<div className="flex items-center gap-4">
{currentUser && !is_archived && (
<IssueSubscription
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={issueId}
currentUserId={currentUser?.id}
/>
<IssueSubscription workspaceSlug={workspaceSlug} projectId={projectId} issueId={issueId} />
)}
<button onClick={handleCopyText}>
<Link2 className="h-4 w-4 -rotate-45 text-custom-text-300 hover:text-custom-text-200" />

View file

@ -0,0 +1,188 @@
import { FC } from "react";
import { Controller, useForm } from "react-hook-form";
// ui
import { Button } from "@plane/ui";
// hooks
import useToast from "hooks/use-toast";
// services
import { UserService } from "services/user.service";
// types
import { IUserEmailNotificationSettings } from "@plane/types";
interface IEmailNotificationFormProps {
data: IUserEmailNotificationSettings;
}
// services
const userService = new UserService();
export const EmailNotificationForm: FC<IEmailNotificationFormProps> = (props) => {
const { data } = props;
// toast
const { setToastAlert } = useToast();
// form data
const {
handleSubmit,
control,
setValue,
formState: { isSubmitting, isDirty, dirtyFields },
} = useForm<IUserEmailNotificationSettings>({
defaultValues: {
...data,
},
});
const onSubmit = async (formData: IUserEmailNotificationSettings) => {
// Get the dirty fields from the form data and create a payload
let payload = {};
Object.keys(dirtyFields).forEach((key) => {
payload = {
...payload,
[key]: formData[key as keyof IUserEmailNotificationSettings],
};
});
await userService
.updateCurrentUserEmailNotificationSettings(payload)
.then(() =>
setToastAlert({
title: "Success",
type: "success",
message: "Email Notification Settings updated successfully",
})
)
.catch((err) => console.error(err));
};
return (
<>
<div className="flex gap-2 items-center pt-6 mb-2 pb-6 border-b border-custom-border-100">
<div className="grow">
<div className="pb-1 text-xl font-medium text-custom-text-100">Email notifications</div>
<div className="text-sm font-normal text-custom-text-300">
Stay in the loop on Issues you are subscribed to. Enable this to get notified.
</div>
</div>
</div>
<div className="pt-2 text-lg font-medium text-custom-text-100">Notify me when:</div>
{/* Notification Settings */}
<div className="flex flex-col py-2">
<div className="flex gap-2 items-center pt-6">
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">Property changes</div>
<div className="text-sm font-normal text-custom-text-300">
Notify me when issues properties like assignees, priority, estimates or anything else changes.
</div>
</div>
<div className="shrink-0">
<Controller
control={control}
name="property_change"
render={({ field: { value, onChange } }) => (
<input
type="checkbox"
checked={value}
onChange={() => onChange(!value)}
className="w-3.5 h-3.5 mx-2 cursor-pointer !border-custom-border-100"
/>
)}
/>
</div>
</div>
<div className="flex gap-2 items-center pt-6 pb-2">
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">State Change</div>
<div className="text-sm font-normal text-custom-text-300">
Notify me when the issues moves to a different state
</div>
</div>
<div className="shrink-0">
<Controller
control={control}
name="state_change"
render={({ field: { value, onChange } }) => (
<input
type="checkbox"
checked={value}
onChange={() => {
if (!value) setValue("issue_completed", true);
onChange(!value);
}}
className="w-3.5 h-3.5 mx-2 cursor-pointer"
/>
)}
/>
</div>
</div>
<div className="flex gap-2 items-center border-0 border-l-[3px] border-custom-border-300 pl-3">
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">Issue completed</div>
<div className="text-sm font-normal text-custom-text-300">Notify me only when an issue is completed</div>
</div>
<div className="shrink-0">
<Controller
control={control}
name="issue_completed"
render={({ field: { value, onChange } }) => (
<input
type="checkbox"
checked={value}
onChange={() => onChange(!value)}
className="w-3.5 h-3.5 mx-2 cursor-pointer"
/>
)}
/>
</div>
</div>
<div className="flex gap-2 items-center pt-6">
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">Comments</div>
<div className="text-sm font-normal text-custom-text-300">
Notify me when someone leaves a comment on the issue
</div>
</div>
<div className="shrink-0">
<Controller
control={control}
name="comment"
render={({ field: { value, onChange } }) => (
<input
type="checkbox"
checked={value}
onChange={() => onChange(!value)}
className="w-3.5 h-3.5 mx-2 cursor-pointer"
/>
)}
/>
</div>
</div>
<div className="flex gap-2 items-center pt-6">
<div className="grow">
<div className="pb-1 text-base font-medium text-custom-text-100">Mentions</div>
<div className="text-sm font-normal text-custom-text-300">
Notify me only when someone mentions me in the comments or description
</div>
</div>
<div className="shrink-0">
<Controller
control={control}
name="mention"
render={({ field: { value, onChange } }) => (
<input
type="checkbox"
checked={value}
onChange={() => onChange(!value)}
className="w-3.5 h-3.5 mx-2 cursor-pointer"
/>
)}
/>
</div>
</div>
</div>
<div className="flex items-center py-12">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting} disabled={!isDirty}>
{isSubmitting ? "Saving..." : "Save changes"}
</Button>
</div>
</>
);
};

View file

@ -0,0 +1 @@
export * from "./email-notification-form";

View file

@ -33,7 +33,7 @@ export const PROFILE_ACTION_LINKS: {
{
key: "preferences",
label: "Preferences",
href: `/profile/preferences`,
href: `/profile/preferences/theme`,
highlight: (pathname: string) => pathname.includes("/profile/preferences"),
Icon: Settings2,
},

View file

@ -0,0 +1,2 @@
export * from "./layout";
export * from "./sidebar";

View file

@ -0,0 +1,25 @@
import { FC, ReactNode } from "react";
// layout
import { ProfileSettingsLayout } from "layouts/settings-layout";
import { ProfilePreferenceSettingsSidebar } from "./sidebar";
interface IProfilePreferenceSettingsLayout {
children: ReactNode;
header?: ReactNode;
}
export const ProfilePreferenceSettingsLayout: FC<IProfilePreferenceSettingsLayout> = (props) => {
const { children, header } = props;
return (
<ProfileSettingsLayout>
<div className="relative flex h-screen w-full overflow-hidden">
<ProfilePreferenceSettingsSidebar />
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
{header}
<div className="h-full w-full overflow-x-hidden overflow-y-scroll">{children}</div>
</main>
</div>
</ProfileSettingsLayout>
);
};

View file

@ -0,0 +1,43 @@
import React from "react";
import { useRouter } from "next/router";
import Link from "next/link";
export const ProfilePreferenceSettingsSidebar = () => {
const router = useRouter();
const profilePreferenceLinks: Array<{
label: string;
href: string;
}> = [
{
label: "Theme",
href: `/profile/preferences/theme`,
},
{
label: "Email",
href: `/profile/preferences/email`,
},
];
return (
<div className="flex w-96 flex-col gap-6 px-8 py-12">
<div className="flex flex-col gap-4">
<span className="text-xs font-semibold text-custom-text-400">Preference</span>
<div className="flex w-full flex-col gap-2">
{profilePreferenceLinks.map((link) => (
<Link key={link.href} href={link.href}>
<div
className={`rounded-md px-4 py-2 text-sm font-medium ${
(link.label === "Import" ? router.asPath.includes(link.href) : router.asPath === link.href)
? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
}`}
>
{link.label}
</div>
</Link>
))}
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,36 @@
import { ReactElement } from "react";
import useSWR from "swr";
// layouts
import { ProfilePreferenceSettingsLayout } from "layouts/settings-layout/profile/preferences";
// components
import { EmailNotificationForm } from "components/profile/preferences";
// services
import { UserService } from "services/user.service";
// type
import { NextPageWithLayout } from "lib/types";
// services
const userService = new UserService();
const ProfilePreferencesThemePage: NextPageWithLayout = () => {
// fetching user email notification settings
const { data } = useSWR("CURRENT_USER_EMAIL_NOTIFICATION_SETTINGS", () =>
userService.currentUserEmailNotificationSettings()
);
if (!data) {
return null;
}
return (
<div className="mx-auto mt-8 h-full w-full overflow-y-auto px-6 lg:px-20 pb-8">
<EmailNotificationForm data={data} />
</div>
);
};
ProfilePreferencesThemePage.getLayout = function getLayout(page: ReactElement) {
return <ProfilePreferenceSettingsLayout>{page}</ProfilePreferenceSettingsLayout>;
};
export default ProfilePreferencesThemePage;

View file

@ -5,7 +5,7 @@ import { useTheme } from "next-themes";
import { useUser } from "hooks/store";
import useToast from "hooks/use-toast";
// layouts
import { ProfileSettingsLayout } from "layouts/settings-layout";
import { ProfilePreferenceSettingsLayout } from "layouts/settings-layout/profile/preferences";
// components
import { CustomThemeSelector, ThemeSwitch } from "components/core";
// ui
@ -15,7 +15,7 @@ import { I_THEME_OPTION, THEME_OPTIONS } from "constants/themes";
// type
import { NextPageWithLayout } from "lib/types";
const ProfilePreferencesPage: NextPageWithLayout = observer(() => {
const ProfilePreferencesThemePage: NextPageWithLayout = observer(() => {
// states
const [currentTheme, setCurrentTheme] = useState<I_THEME_OPTION | null>(null);
// store hooks
@ -48,7 +48,7 @@ const ProfilePreferencesPage: NextPageWithLayout = observer(() => {
return (
<>
{currentUser ? (
<div className="mx-auto mt-16 h-full w-full overflow-y-auto px-8 pb-8 lg:w-3/5">
<div className="mx-auto mt-14 h-full w-full overflow-y-auto px-6 lg:px-20 pb-8">
<div className="flex items-center border-b border-custom-border-100 pb-3.5">
<h3 className="text-xl font-medium">Preferences</h3>
</div>
@ -72,8 +72,8 @@ const ProfilePreferencesPage: NextPageWithLayout = observer(() => {
);
});
ProfilePreferencesPage.getLayout = function getLayout(page: ReactElement) {
return <ProfileSettingsLayout>{page}</ProfileSettingsLayout>;
ProfilePreferencesThemePage.getLayout = function getLayout(page: ReactElement) {
return <ProfilePreferenceSettingsLayout>{page}</ProfilePreferenceSettingsLayout>;
};
export default ProfilePreferencesPage;
export default ProfilePreferencesThemePage;

View file

@ -10,7 +10,7 @@ import type {
IUserProfileProjectSegregation,
IUserSettings,
IUserWorkspaceDashboard,
TIssueMap,
IUserEmailNotificationSettings,
} from "@plane/types";
// helpers
import { API_BASE_URL } from "helpers/common.helper";
@ -69,6 +69,14 @@ export class UserService extends APIService {
});
}
async currentUserEmailNotificationSettings(): Promise<IUserEmailNotificationSettings> {
return this.get("/api/users/me/notification-preferences/")
.then((response) => response?.data)
.catch((error) => {
throw error?.response;
});
}
async updateUser(data: Partial<IUser>): Promise<any> {
return this.patch("/api/users/me/", data)
.then((response) => response?.data)
@ -97,6 +105,14 @@ export class UserService extends APIService {
});
}
async updateCurrentUserEmailNotificationSettings(data: Partial<IUserEmailNotificationSettings>): Promise<any> {
return this.patch("/api/users/me/notification-preferences/", data)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async getUserActivity(): Promise<IUserActivityResponse> {
return this.get(`/api/users/me/activities/`)
.then((response) => response?.data)