mirror of
https://github.com/makeplane/plane.git
synced 2026-07-13 22:09:12 +02:00
added ui for pop over
This commit is contained in:
@@ -1,32 +1,34 @@
|
||||
import { FC, useMemo, useState } from "react";
|
||||
import { FC, useMemo, useState, Fragment } from "react";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Popover } from "@headlessui/react";
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import { Row } from "@plane/ui";
|
||||
import { MemberDropdown } from "@/components/dropdowns";
|
||||
//helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { calculateTimeAgo, convertToEpoch } from "@/helpers/date-time.helper";
|
||||
//store
|
||||
import { useWorkspaceNotifications } from "@/hooks/store";
|
||||
//components
|
||||
import { NotificationCardPreview } from "@/plane-web/components/workspace-notifications";
|
||||
import { uniq } from "lodash";
|
||||
export interface INotificationItem {
|
||||
issueId: string;
|
||||
workspaceSlug: string;
|
||||
}
|
||||
export const NotificationItem: FC<INotificationItem> = observer((props) => {
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
const { issueId, workspaceSlug } = props;
|
||||
|
||||
const { issueId } = props;
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLDivElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
const { groupedNotifications } = useWorkspaceNotifications();
|
||||
const notificationGroup = groupedNotifications[issueId];
|
||||
const issue = notificationGroup[0].data?.issue;
|
||||
const unreadCount = notificationGroup.filter((e) => !e.read_at).length;
|
||||
const authorIds = notificationGroup
|
||||
.map((e) => e.triggered_by)
|
||||
.filter((id) => id != undefined && id != null) as string[];
|
||||
const projectId = notificationGroup[0].project;
|
||||
|
||||
if (!notificationGroup || !issue || !authorIds) return <></>;
|
||||
const authorIds = uniq(notificationGroup.map((e) => e.triggered_by).filter((id) => id != undefined && id != null));
|
||||
|
||||
const latestNotificationTime = useMemo(() => {
|
||||
const latestNotification = orderBy(notificationGroup, (n) => convertToEpoch(n.created_at), "desc")[0];
|
||||
@@ -34,15 +36,26 @@ export const NotificationItem: FC<INotificationItem> = observer((props) => {
|
||||
}, [notificationGroup]);
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: "right-end",
|
||||
placement: "right-start",
|
||||
});
|
||||
|
||||
// states
|
||||
const [openState, setOpenState] = useState<boolean>(false);
|
||||
|
||||
if (!notificationGroup || !issue || !authorIds || !projectId) return <></>;
|
||||
|
||||
return (
|
||||
<Popover as="div" className={""}>
|
||||
<Popover.Button className="w-full">
|
||||
<Popover.Button as={Fragment}>
|
||||
<div
|
||||
className="border-b transition-all py-4 border-custom-border-200 cursor-pointer group w-full"
|
||||
className={cn(
|
||||
"border-b transition-all py-4 border-custom-border-200 cursor-pointer group w-full",
|
||||
unreadCount > 0 ? "bg-custom-primary-100/5" : ""
|
||||
)}
|
||||
ref={setReferenceElement}
|
||||
onClick={() => {}}
|
||||
onMouseEnter={() => setOpenState(true)}
|
||||
onMouseLeave={() => setOpenState(false)}
|
||||
>
|
||||
{/* Issue card header */}
|
||||
<Row className="flex items-center gap-1">
|
||||
@@ -50,11 +63,11 @@ export const NotificationItem: FC<INotificationItem> = observer((props) => {
|
||||
{issue.sequence_id}-{issue.identifier}
|
||||
</span>
|
||||
<div className="flex-1 flex gap-2 justify-between items-center">
|
||||
<span className="overflow-hidden whitespace-normal break-all truncate line-clamp-1 text-sm text-custom-text-100">
|
||||
<span className="overflow-hidden whitespace-normal text-sm break-all truncate line-clamp-1 text-custom-text-100">
|
||||
{issue.name}
|
||||
</span>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-xs px-2 font-medium py-1 text-white bg-custom-primary-300 rounded-lg">
|
||||
<span className="text-xs px-2 font-bold py-1 text-white bg-custom-primary-300 rounded-lg">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -80,11 +93,23 @@ export const NotificationItem: FC<INotificationItem> = observer((props) => {
|
||||
</Row>
|
||||
</div>
|
||||
</Popover.Button>
|
||||
<Popover.Panel {...attributes.popper} className={""}>
|
||||
<div ref={setPopperElement} className={"absolute z-10 right-0 translate-x-[100%]"}>
|
||||
<NotificationCardPreview notificationGroup={notificationGroup} />
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
<Transition
|
||||
as={"div"}
|
||||
enter="transition ease-out duration-200"
|
||||
show={openState && unreadCount > 0}
|
||||
onMouseEnter={() => setOpenState(true)}
|
||||
onMouseLeave={() => setOpenState(false)}
|
||||
>
|
||||
<Popover.Panel {...attributes.popper} className={""}>
|
||||
<div ref={setPopperElement} className={"absolute z-10"} style={styles.popper}>
|
||||
<NotificationCardPreview
|
||||
notificationGroup={notificationGroup}
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { FC, ReactElement, ReactNode } from "react";
|
||||
// hooks
|
||||
import { DiceIcon, LayersIcon, Tooltip, ContrastIcon, ArchiveIcon, Intake, Avatar } from "@plane/ui";
|
||||
import { renderFormattedTime, renderFormattedDate, calculateTimeAgo } from "@/helpers/date-time.helper";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
CalendarDays,
|
||||
LayoutPanelTop,
|
||||
MessageSquare,
|
||||
Paperclip,
|
||||
Signal,
|
||||
Tag,
|
||||
Triangle,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { TSvgIcons } from "@/plane-web/components/workspace-project-states";
|
||||
import { IUserLite } from "@plane/types";
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// ui
|
||||
// components
|
||||
// helpers
|
||||
|
||||
type TIssueActivityBlock = {
|
||||
createdAt: string | undefined;
|
||||
notificationField: string;
|
||||
ends: "top" | "bottom" | undefined;
|
||||
children: ReactNode;
|
||||
triggeredBy: IUserLite | undefined;
|
||||
};
|
||||
|
||||
export const IssueActivityBlock: FC<TIssueActivityBlock> = (props) => {
|
||||
const { ends, children, createdAt, notificationField, triggeredBy } = props;
|
||||
|
||||
const acitvityIconsMap: Record<string, ReactElement> = {
|
||||
state: <LayersIcon width={14} height={14} className="text-custom-text-200" aria-hidden="true" />,
|
||||
name: <MessageSquare size={14} className="text-custom-text-200" aria-hidden="true" />,
|
||||
description: <MessageSquare size={14} className="text-custom-text-200" aria-hidden="true" />,
|
||||
assignees: <Users className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-200" />,
|
||||
priority: <Signal size={14} className="text-custom-text-200" aria-hidden="true" />,
|
||||
estimate_point: <Triangle size={14} className="text-custom-text-200" aria-hidden="true" />,
|
||||
parent: <LayoutPanelTop size={14} className="text-custom-text-200" aria-hidden="true" />,
|
||||
start_date: <CalendarDays size={14} className="text-custom-text-200" aria-hidden="true" />,
|
||||
target_date: <CalendarDays size={14} className="text-custom-text-200" aria-hidden="true" />,
|
||||
cycles: <ContrastIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />,
|
||||
modules: <DiceIcon className="h-4 w-4 flex-shrink-0 text-custom-text-200" />,
|
||||
labels: <Tag size={14} className="text-custom-text-200" aria-hidden="true" />,
|
||||
link: <MessageSquare size={14} className="text-custom-text-200" aria-hidden="true" />,
|
||||
attachment: <Paperclip size={14} className="text-custom-text-200" aria-hidden="true" />,
|
||||
archived_at: <ArchiveIcon className="h-3.5 w-3.5 text-custom-text-200" aria-hidden="true" />,
|
||||
inbox: <Intake className="h-4 w-4 flex-shrink-0 text-custom-text-200" />,
|
||||
type: <ArrowRightLeft className="h-3.5 w-3.5 flex-shrink-0 text-custom-text-200" />,
|
||||
};
|
||||
const { isMobile } = usePlatformOS();
|
||||
return (
|
||||
<div
|
||||
className={`relative flex w-full items-center gap-3 text-xs ${
|
||||
ends === "top" ? `pb-2` : ends === "bottom" ? `pt-2` : `py-2`
|
||||
}`}
|
||||
>
|
||||
<div className="absolute left-[13px] top-0 bottom-0 w-0.5 bg-custom-background-80" aria-hidden />
|
||||
{notificationField !== "comment" ? (
|
||||
<div className="flex-shrink-0 ring-6 w-7 h-7 rounded-full overflow-hidden flex justify-center items-center z-[4] bg-custom-background-80 text-custom-text-200">
|
||||
{acitvityIconsMap[notificationField]}
|
||||
</div>
|
||||
) : triggeredBy ? (
|
||||
<div className="z-[4]">
|
||||
<Avatar src={getFileURL(triggeredBy.avatar_url)} name={triggeredBy.display_name} size={28} />
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<div className="w-full truncate text-custom-text-200 flex gap-2">
|
||||
<span className=""> {children} </span>
|
||||
<span className="text-custom-text-100">
|
||||
{createdAt && (
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={`${renderFormattedDate(createdAt)}, ${renderFormattedTime(createdAt)}`}
|
||||
>
|
||||
<span className="whitespace-nowrap"> {calculateTimeAgo(createdAt)}</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +1,3 @@
|
||||
export * from "./root";
|
||||
export * from "./preview-activity";
|
||||
export * from "./acitvity-block";
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { FC } from "react";
|
||||
import { Network } from "lucide-react";
|
||||
import { IUserLite, TNotification } from "@plane/types";
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { sanitizeCommentForNotification } from "@/helpers/notification.helper";
|
||||
import { replaceUnderscoreIfSnakeCase, stripAndTruncateHTML } from "@/helpers/string.helper";
|
||||
import { IssueActivityBlock } from "./acitvity-block";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
|
||||
export type TNotificationPreviewActivity = {
|
||||
notification: TNotification;
|
||||
ends: "top" | "bottom" | undefined;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
export const NotificationPreviewActivity: FC<TNotificationPreviewActivity> = (props) => {
|
||||
const { notification, workspaceSlug, ends } = props;
|
||||
const notificationField = notification?.data?.issue_activity.field || undefined;
|
||||
const notificationTriggeredBy = notification.triggered_by_details || undefined;
|
||||
const triggeredBy = notification.triggered_by_details;
|
||||
|
||||
if (!workspaceSlug || !notification.id || !notification?.id || !notificationField) return <></>;
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-2 items-center", ends === "bottom" ? "pb-3" : "")}>
|
||||
<IssueActivityBlock
|
||||
ends={ends}
|
||||
notificationField={notificationField}
|
||||
createdAt={notification?.created_at}
|
||||
triggeredBy={triggeredBy}
|
||||
>
|
||||
<div className="w-full whitespace-normal text-sm text-custom-text-100">
|
||||
{!notification.message ? (
|
||||
<>
|
||||
<span className="font-semibold">
|
||||
{notificationTriggeredBy?.is_bot
|
||||
? notificationTriggeredBy?.first_name
|
||||
: notificationTriggeredBy?.display_name}{" "}
|
||||
</span>
|
||||
{!["comment", "archived_at"].includes(notificationField) && notification?.data?.issue_activity.verb}{" "}
|
||||
{notificationField === "comment"
|
||||
? "commented"
|
||||
: notificationField === "archived_at"
|
||||
? notification?.data?.issue_activity.new_value === "restore"
|
||||
? "restored the issue"
|
||||
: "archived the issue"
|
||||
: notificationField === "None"
|
||||
? null
|
||||
: replaceUnderscoreIfSnakeCase(notificationField)}{" "}
|
||||
{notification?.data?.issue_activity.verb !== "deleted" && (
|
||||
<>
|
||||
{!["comment", "archived_at", "None"].includes(notificationField) ? "to" : ""}
|
||||
<span className="font-semibold">
|
||||
{" "}
|
||||
{notificationField !== "None" ? (
|
||||
notificationField !== "comment" ? (
|
||||
notificationField === "target_date" ? (
|
||||
renderFormattedDate(notification?.data?.issue_activity.new_value)
|
||||
) : notificationField === "attachment" ? (
|
||||
"the issue"
|
||||
) : notificationField === "description" ? (
|
||||
stripAndTruncateHTML(notification?.data?.issue_activity.new_value || "", 55)
|
||||
) : notificationField === "archived_at" ? null : (
|
||||
notification?.data?.issue_activity.new_value
|
||||
)
|
||||
) : (
|
||||
<div>comment block here</div>
|
||||
)
|
||||
) : (
|
||||
"the issue and assigned it to you."
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="semi-bold">{notification.message}</span>
|
||||
)}
|
||||
</div>
|
||||
</IssueActivityBlock>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,30 +1,46 @@
|
||||
import { FC } from "react";
|
||||
import orderBy from "lodash/orderBy";
|
||||
import { TNotification } from "@plane/types";
|
||||
import { Row } from "@plane/ui";
|
||||
|
||||
import { convertToEpoch } from "@/helpers/date-time.helper";
|
||||
import { NotificationPreviewActivity } from "@/plane-web/components/workspace-notifications";
|
||||
export type TNotificationCardPreview = {
|
||||
notificationGroup: TNotification[];
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
};
|
||||
export const NotificationCardPreview: FC<TNotificationCardPreview> = (props) => {
|
||||
const { notificationGroup } = props;
|
||||
const { notificationGroup, workspaceSlug } = props;
|
||||
const issue = notificationGroup[0].data?.issue;
|
||||
const unreadCount = notificationGroup.filter((e) => !e.read_at).length;
|
||||
|
||||
if (!issue) return;
|
||||
if (!issue || !issue.id || !workspaceSlug) return;
|
||||
|
||||
return (
|
||||
<div className="p-3 border rounded-md shadow-md border-custom-border-100">
|
||||
<div className="pt-3 px-3 border rounded-md shadow-md border-custom-border-100 bg-white">
|
||||
<div className="flex justify-between gap-4">
|
||||
<p className="text-sm after:-100">
|
||||
{issue.identifier}-{issue.sequence_id}
|
||||
</p>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-xs first-letter:font-medium py-1 px-2 text-white bg-custom-primary-300 rounded-lg">
|
||||
{unreadCount} new updates
|
||||
<span className="text-xs font-bold py-1 px-2 text-white bg-custom-primary-300 rounded-lg">
|
||||
{unreadCount} new update{unreadCount > 1 && "s"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium">{issue.name}</p>
|
||||
<div>{/* Lastest notifications */}</div>
|
||||
<p className="font-medium mt-2 mb-8">{issue.name}</p>
|
||||
<div className="max-h-60 overflow-y-scroll vertical-scrollbar scrollbar-sm">
|
||||
{orderBy(notificationGroup, (n) => convertToEpoch(n.created_at), "desc")
|
||||
.filter((n) => !n.read_at)
|
||||
.filter((n) => !!n)
|
||||
.map((notification, index, { length }) => (
|
||||
<NotificationPreviewActivity
|
||||
workspaceSlug={workspaceSlug}
|
||||
notification={notification}
|
||||
key={notification.id}
|
||||
ends={length === 1 ? undefined : index === 0 ? "top" : index === length - 1 ? "bottom" : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -32,8 +32,7 @@ export const NotificationCardListRoot: FC<TNotificationCardListRoot> = observer(
|
||||
return (
|
||||
<>
|
||||
{notificationIssueIds.map((issueId: string) => (
|
||||
// <NotificationItem key={notificationId} workspaceSlug={workspaceSlug} notificationId={notificationId} />
|
||||
<NotificationItem issueId={issueId} key={issueId} />
|
||||
<NotificationItem issueId={issueId} key={issueId} workspaceSlug={workspaceSlug} />
|
||||
))}
|
||||
|
||||
{/* fetch next page notifications */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { orderBy, groupBy } from "lodash";
|
||||
import { orderBy, uniqBy } from "lodash";
|
||||
import { action, makeObservable, observable, runInAction, set } from "mobx";
|
||||
//store
|
||||
import { computedFn } from "mobx-utils";
|
||||
@@ -42,7 +42,17 @@ export class WorkspaceNotificationStore extends WorkspaceNotificationStoreCore i
|
||||
* @param { INotification[] } notifications
|
||||
*/
|
||||
groupNotificationsById = action((notifications: TNotification[]) => {
|
||||
this.groupedNotifications = groupBy(notifications, (n) => n.entity_identifier);
|
||||
notifications.forEach((notification) => {
|
||||
if (!notification.entity_identifier) return;
|
||||
/**
|
||||
* Apply filters here and then add to group
|
||||
*/
|
||||
const existingNotifications = this.groupedNotifications[notification.entity_identifier] || [];
|
||||
this.groupedNotifications[notification.entity_identifier] = uniqBy(
|
||||
[...existingNotifications, notification],
|
||||
"id"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
notificationIssueIdsByWorkspaceId = computedFn((workspaceId: string) => {
|
||||
|
||||
Reference in New Issue
Block a user