added ui for pop over

This commit is contained in:
vamsi krishna
2024-12-04 19:29:40 +05:30
parent fe5ce937bd
commit d4e6a46a82
7 changed files with 256 additions and 32 deletions

View File

@@ -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>
);
});

View File

@@ -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>
);
};

View File

@@ -1 +1,3 @@
export * from "./root";
export * from "./preview-activity";
export * from "./acitvity-block";

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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 */}

View File

@@ -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) => {