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:
parent
c1e1b81b99
commit
f27efb80e1
35 changed files with 2482 additions and 314 deletions
|
|
@ -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")) && (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
188
web/components/profile/preferences/email-notification-form.tsx
Normal file
188
web/components/profile/preferences/email-notification-form.tsx
Normal 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 issue’s 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
1
web/components/profile/preferences/index.ts
Normal file
1
web/components/profile/preferences/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./email-notification-form";
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
2
web/layouts/settings-layout/profile/preferences/index.ts
Normal file
2
web/layouts/settings-layout/profile/preferences/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./layout";
|
||||
export * from "./sidebar";
|
||||
25
web/layouts/settings-layout/profile/preferences/layout.tsx
Normal file
25
web/layouts/settings-layout/profile/preferences/layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
43
web/layouts/settings-layout/profile/preferences/sidebar.tsx
Normal file
43
web/layouts/settings-layout/profile/preferences/sidebar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
36
web/pages/profile/preferences/email.tsx
Normal file
36
web/pages/profile/preferences/email.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue