snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-15 15:14:58 +05:00
commit 23059a1de3
5252 changed files with 573834 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,45 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import useSWR from "swr";
// plane imports
import { getNumberCount } from "@plane/utils";
// components
import { CountChip } from "@/components/common/count-chip";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
type TNotificationAppSidebarOption = {
workspaceSlug: string;
};
export const NotificationAppSidebarOption = observer(function NotificationAppSidebarOption(
props: TNotificationAppSidebarOption
) {
const { workspaceSlug } = props;
// hooks
const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications();
useSWR(
workspaceSlug ? "WORKSPACE_UNREAD_NOTIFICATION_COUNT" : null,
workspaceSlug ? () => getUnreadNotificationsCount(workspaceSlug) : null
);
// derived values
const isMentionsEnabled = unreadNotificationsCount.mention_unread_notifications_count > 0 ? true : false;
const totalNotifications = isMentionsEnabled
? unreadNotificationsCount.mention_unread_notifications_count
: unreadNotificationsCount.total_unread_notifications_count;
if (totalNotifications <= 0) return <></>;
return (
<div className="ml-auto">
<CountChip count={`${isMentionsEnabled ? `@ ` : ``}${getNumberCount(totalNotifications)}`} />
</div>
);
});

View File

@@ -0,0 +1,31 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { replaceUnderscoreIfSnakeCase } from "@plane/utils";
import type { TNotificationContentMap } from "@/components/workspace-notifications/sidebar/notification-card/content";
// Additional notification content map for CE (empty - EE extends this)
export const ADDITIONAL_NOTIFICATION_CONTENT_MAP: TNotificationContentMap = {};
// Fallback action renderer for fields not in the map
export const renderAdditionalAction = (notificationField: string, verb: string | undefined) => {
const baseAction = !["comment", "archived_at"].includes(notificationField) ? verb : "";
return `${baseAction} ${replaceUnderscoreIfSnakeCase(notificationField)}`;
};
// Fallback value renderer for fields not in the map
export const renderAdditionalValue = (
_notificationField: string | undefined,
newValue: string | undefined,
_oldValue: string | undefined
) => newValue;
export const shouldShowConnector = (notificationField: string | undefined) =>
!["comment", "archived_at", "None", "assignees", "labels", "start_date", "target_date", "parent"].includes(
notificationField || ""
);
export const shouldRender = (notificationField: string | undefined, verb: string | undefined) => verb !== "deleted";

View File

@@ -0,0 +1,62 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { ENotificationLoader, ENotificationQueryParamType } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// components
import { NotificationItem } from "@/components/workspace-notifications/sidebar/notification-card/item";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
type TNotificationCardListRoot = {
workspaceSlug: string;
workspaceId: string;
};
export const NotificationCardListRoot = observer(function NotificationCardListRoot(props: TNotificationCardListRoot) {
const { workspaceSlug, workspaceId } = props;
// hooks
const { loader, paginationInfo, getNotifications, notificationIdsByWorkspaceId } = useWorkspaceNotifications();
const notificationIds = notificationIdsByWorkspaceId(workspaceId);
const { t } = useTranslation();
const getNextNotifications = async () => {
try {
await getNotifications(workspaceSlug, ENotificationLoader.PAGINATION_LOADER, ENotificationQueryParamType.NEXT);
} catch (error) {
console.error(error);
}
};
if (!workspaceSlug || !workspaceId || !notificationIds) return <></>;
return (
<div>
{notificationIds.map((notificationId: string) => (
<NotificationItem key={notificationId} workspaceSlug={workspaceSlug} notificationId={notificationId} />
))}
{/* fetch next page notifications */}
{paginationInfo && paginationInfo?.next_page_results && (
<>
{loader === ENotificationLoader.PAGINATION_LOADER ? (
<div className="flex items-center justify-center py-4 text-13 font-medium">
<div className="text-accent-secondary">{t("loading")}...</div>
</div>
) : (
// oxlint-disable-next-line jsx_a11y/click-events-have-key-events oxlint-disable-next-line jsx_a11y/no-static-element-interactions
<div className="flex items-center justify-center py-4 text-13 font-medium" onClick={getNextNotifications}>
<div className="cursor-pointer text-accent-secondary transition-all hover:text-accent-primary">
{t("load_more")}
</div>
</div>
)}
</>
)}
</div>
);
});

View File

@@ -0,0 +1,118 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useEffect } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// plane imports
import { ENotificationLoader, ENotificationQueryParamType } from "@plane/constants";
import { EmptyStateCompact } from "@plane/propel/empty-state";
import { cn } from "@plane/utils";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties";
import { useNotificationPreview } from "@/hooks/use-notification-preview";
// local imports
import { InboxContentRoot } from "../inbox/content";
type NotificationsRootProps = {
workspaceSlug?: string;
};
export const NotificationsRoot = observer(function NotificationsRoot({ workspaceSlug }: NotificationsRootProps) {
// hooks
const { currentWorkspace } = useWorkspace();
const {
currentSelectedNotificationId,
setCurrentSelectedNotificationId,
notificationLiteByNotificationId,
notificationIdsByWorkspaceId,
getNotifications,
} = useWorkspaceNotifications();
const { fetchUserProjectInfo } = useUserPermissions();
const { isWorkItem, PeekOverviewComponent, setPeekWorkItem } = useNotificationPreview();
// derived values
const { workspace_slug, project_id, issue_id, is_inbox_issue } =
notificationLiteByNotificationId(currentSelectedNotificationId);
// fetching workspace work item properties
useWorkspaceIssueProperties(workspaceSlug);
// fetch workspace notifications
const notificationMutation =
currentWorkspace && notificationIdsByWorkspaceId(currentWorkspace.id)
? ENotificationLoader.MUTATION_LOADER
: ENotificationLoader.INIT_LOADER;
const notificationLoader =
currentWorkspace && notificationIdsByWorkspaceId(currentWorkspace.id)
? ENotificationQueryParamType.CURRENT
: ENotificationQueryParamType.INIT;
useSWR(
currentWorkspace?.slug ? `WORKSPACE_NOTIFICATION_${currentWorkspace?.slug}` : null,
currentWorkspace?.slug
? () => getNotifications(currentWorkspace?.slug, notificationMutation, notificationLoader)
: null
);
// fetching user project member info
const { isLoading: projectMemberInfoLoader } = useSWR(
workspace_slug && project_id && is_inbox_issue
? `PROJECT_MEMBER_PERMISSION_INFO_${workspace_slug}_${project_id}`
: null,
workspace_slug && project_id && is_inbox_issue ? () => fetchUserProjectInfo(workspace_slug, project_id) : null
);
const embedRemoveCurrentNotification = useCallback(
() => setCurrentSelectedNotificationId(undefined),
[setCurrentSelectedNotificationId]
);
// clearing up the selected notifications when unmounting the page
useEffect(
() => () => {
setPeekWorkItem(undefined);
},
[setCurrentSelectedNotificationId, setPeekWorkItem]
);
return (
<div className={cn("h-full w-full overflow-hidden", isWorkItem && "overflow-y-auto")}>
{!currentSelectedNotificationId ? (
<div className="flex size-full items-center justify-center">
<EmptyStateCompact assetKey="unknown" assetClassName="size-20" />
</div>
) : (
<>
{is_inbox_issue === true && workspace_slug && project_id && issue_id ? (
<>
{projectMemberInfoLoader ? (
<div className="flex h-full w-full items-center justify-center">
<LogoSpinner />
</div>
) : (
<InboxContentRoot
setIsMobileSidebar={() => {}}
isMobileSidebar={false}
workspaceSlug={workspace_slug}
projectId={project_id}
inboxIssueId={issue_id}
isNotificationEmbed
embedRemoveCurrentNotification={embedRemoveCurrentNotification}
/>
)}
</>
) : (
<PeekOverviewComponent embedIssue embedRemoveCurrentNotification={embedRemoveCurrentNotification} />
)}
</>
)}
</div>
);
});

View File

@@ -0,0 +1,37 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { ENotificationTab } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EmptyStateCompact } from "@plane/propel/empty-state";
type TNotificationEmptyStateProps = {
currentNotificationTab: ENotificationTab;
};
export const NotificationEmptyState = observer(function NotificationEmptyState({
currentNotificationTab,
}: TNotificationEmptyStateProps) {
// plane imports
const { t } = useTranslation();
return (
<>
<EmptyStateCompact
assetKey="inbox"
assetClassName="size-24"
title={
currentNotificationTab === ENotificationTab.ALL
? t("workspace_empty_state.inbox_sidebar_all.title")
: t("workspace_empty_state.inbox_sidebar_mentions.title")
}
className="max-w-56"
/>
</>
);
});

View File

@@ -0,0 +1,73 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { ENotificationFilterType, FILTER_TYPE_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { PillButton } from "@makeplane/propel/components/pill";
import { CloseOutline } from "@makeplane/propel/icons";
import { Header, EHeaderVariant } from "@plane/ui";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
type TAppliedFilters = {
workspaceSlug: string;
};
export const AppliedFilters = observer(function AppliedFilters(props: TAppliedFilters) {
const { workspaceSlug } = props;
// hooks
const { filters, updateFilters } = useWorkspaceNotifications();
const { t } = useTranslation();
// derived values
const isFiltersEnabled = Object.entries(filters.type || {}).some(([, value]) => value);
const handleFilterTypeChange = (filterType: ENotificationFilterType, filterValue: boolean) =>
updateFilters("type", {
...filters.type,
[filterType]: filterValue,
});
const handleClearFilters = () => {
updateFilters("type", {
[ENotificationFilterType.ASSIGNED]: false,
[ENotificationFilterType.CREATED]: false,
[ENotificationFilterType.SUBSCRIBED]: false,
});
};
if (!isFiltersEnabled || !workspaceSlug) return <></>;
return (
<Header variant={EHeaderVariant.TERNARY}>
<Header.LeftItem className="w-full">
{FILTER_TYPE_OPTIONS.map((filter) => {
const isSelected = filters?.type?.[filter?.value] || false;
if (!isSelected) return <></>;
return (
<PillButton
key={filter.value}
type="button"
size="md"
variant="outline"
label={t(filter.i18n_label)}
endIcon={<CloseOutline className="h-3 w-3" />}
onClick={() => handleFilterTypeChange(filter?.value, !isSelected)}
/>
);
})}
<PillButton
type="button"
size="md"
variant="outline"
label={t("common.clear_all")}
endIcon={<CloseOutline height={12} width={12} />}
onClick={handleClearFilters}
/>
</Header.LeftItem>
</Header>
);
});

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,58 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import type { ENotificationFilterType } from "@plane/constants";
import { TickOutline } from "@makeplane/propel/icons";
// plane imports
// helpers
import { cn } from "@plane/utils";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
export const NotificationFilterOptionItem = observer(function NotificationFilterOptionItem(props: {
label: string;
value: ENotificationFilterType;
}) {
const { value, label } = props;
// hooks
const { filters, updateFilters } = useWorkspaceNotifications();
const handleFilterTypeChange = (filterType: ENotificationFilterType, filterValue: boolean) =>
updateFilters("type", {
...filters.type,
[filterType]: filterValue,
});
// derived values
const isSelected = filters?.type?.[value] || false;
return (
<div
key={value}
className="flex cursor-pointer items-center gap-2 rounded-xs p-1 px-2 transition-all hover:bg-layer-1"
onClick={() => handleFilterTypeChange(value, !isSelected)}
>
<div
className={cn("flex h-3 w-3 flex-shrink-0 items-center justify-center rounded-xs transition-all", {
"bg-accent-primary text-on-color": isSelected,
"bg-surface-2": !isSelected,
})}
>
{isSelected && <TickOutline className="h-2.5 w-2.5" />}
</div>
<div
className={cn("text-body-xs-medium whitespace-nowrap", {
"text-primary": isSelected,
"text-secondary": !isSelected,
})}
>
{label}
</div>
</div>
);
});

View File

@@ -0,0 +1,43 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { FilterOutline } from "@makeplane/propel/icons";
// plane imports
import type { ENotificationFilterType } from "@plane/constants";
import { FILTER_TYPE_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import { PopoverMenu } from "@plane/ui";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
// local imports
import { NotificationFilterOptionItem } from "./menu-option-item";
import { IconButton } from "@plane/propel/icon-button";
export const NotificationFilter = observer(function NotificationFilter() {
// hooks
const { isMobile } = usePlatformOS();
const { t } = useTranslation();
const translatedFilterTypeOptions = FILTER_TYPE_OPTIONS.map((filter) => ({
...filter,
label: t(filter.i18n_label),
}));
return (
<PopoverMenu
data={translatedFilterTypeOptions}
button={
<Tooltip label={t("notification.options.filters")} side="bottom" disabled={isMobile}>
<IconButton size="base" variant="ghost" icon={FilterOutline} />
</Tooltip>
}
keyExtractor={(item: { label: string; value: ENotificationFilterType }) => item.value}
render={(item) => <NotificationFilterOptionItem {...item} />}
/>
);
});

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,36 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// components
import { cn } from "@plane/utils";
// local imports
import type { TPopoverMenuOptions } from "./root";
export const NotificationMenuOptionItem = observer(function NotificationMenuOptionItem(props: TPopoverMenuOptions) {
const { type, label = "", isActive, prependIcon, appendIcon, onClick } = props;
if (type === "menu-item")
return (
<div
className="mx-2 flex cursor-pointer items-center gap-2 rounded-xs p-1 px-2 transition-all hover:bg-layer-1"
onClick={() => onClick && onClick()}
>
{prependIcon && prependIcon}
<div
className={cn("text-body-xs-medium whitespace-nowrap", {
"text-primary": isActive,
"text-secondary": !isActive,
})}
>
{label}
</div>
{appendIcon && <div className="ml-auto">{appendIcon}</div>}
</div>
);
return <div className="border-b border-subtle" />;
});

View File

@@ -0,0 +1,93 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import { observer } from "mobx-react";
import {
ArchiveOutline,
ClockOutline,
MoreVerticalOutline,
TickCircleOutline,
TickOutline,
} from "@makeplane/propel/icons";
import { useTranslation } from "@plane/i18n";
// plane imports
import type { TNotificationFilter } from "@plane/types";
import { PopoverMenu } from "@plane/ui";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
// local imports
import { NotificationMenuOptionItem } from "./menu-item";
import { IconButton } from "@plane/propel/icon-button";
export type TPopoverMenuOptions = {
key: string;
type: string;
label?: string | undefined;
isActive?: boolean | undefined;
prependIcon?: ReactNode | undefined;
appendIcon?: ReactNode | undefined;
onClick?: (() => void) | undefined;
};
export const NotificationHeaderMenuOption = observer(function NotificationHeaderMenuOption() {
// hooks
const { filters, updateFilters, updateBulkFilters } = useWorkspaceNotifications();
const { t } = useTranslation();
const handleFilterChange = (filterType: keyof TNotificationFilter, filterValue: boolean) =>
updateFilters(filterType, filterValue);
const handleBulkFilterChange = (filter: Partial<TNotificationFilter>) => updateBulkFilters(filter);
const popoverMenuOptions: TPopoverMenuOptions[] = [
{
key: "menu-unread",
type: "menu-item",
label: t("notification.options.show_unread"),
isActive: filters?.read,
prependIcon: <TickCircleOutline className="h-3 w-3 flex-shrink-0" />,
appendIcon: filters?.read ? <TickOutline className="h-3 w-3" /> : undefined,
onClick: () => handleFilterChange("read", !filters?.read),
},
{
key: "menu-archived",
type: "menu-item",
label: t("notification.options.show_archived"),
isActive: filters?.archived,
prependIcon: <ArchiveOutline className="h-3 w-3 flex-shrink-0" />,
appendIcon: filters?.archived ? <TickOutline className="h-3 w-3" /> : undefined,
onClick: () =>
handleBulkFilterChange({
archived: !filters?.archived,
snoozed: false,
}),
},
{
key: "menu-snoozed",
type: "menu-item",
label: t("notification.options.show_snoozed"),
isActive: filters?.snoozed,
prependIcon: <ClockOutline className="h-3 w-3 flex-shrink-0" />,
appendIcon: filters?.snoozed ? <TickOutline className="h-3 w-3" /> : undefined,
onClick: () =>
handleBulkFilterChange({
snoozed: !filters?.snoozed,
archived: false,
}),
},
];
return (
<PopoverMenu
data={popoverMenuOptions}
button={<IconButton size="base" variant="ghost" icon={MoreVerticalOutline} />}
keyExtractor={(item: TPopoverMenuOptions) => item.key}
panelClassName="p-0 py-2 rounded-md border border-subtle bg-surface-1 space-y-1"
render={(item: TPopoverMenuOptions) => <NotificationMenuOptionItem {...item} />}
/>
);
});

View File

@@ -0,0 +1,86 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { CheckDoneOutline, RefreshOutline } from "@makeplane/propel/icons";
// plane imports
import { ENotificationLoader, ENotificationQueryParamType } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import { Spinner } from "@plane/ui";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { usePlatformOS } from "@/hooks/use-platform-os";
// local imports
import { NotificationFilter } from "../../filters/menu";
import { NotificationHeaderMenuOption } from "./menu-option";
import { IconButton } from "@plane/propel/icon-button";
type TNotificationSidebarHeaderOptions = {
workspaceSlug: string;
};
export const NotificationSidebarHeaderOptions = observer(function NotificationSidebarHeaderOptions(
props: TNotificationSidebarHeaderOptions
) {
const { workspaceSlug } = props;
// hooks
const { isMobile } = usePlatformOS();
const { loader, getNotifications, markAllNotificationsAsRead } = useWorkspaceNotifications();
const { t } = useTranslation();
const refreshNotifications = async () => {
if (loader) return;
try {
await getNotifications(workspaceSlug, ENotificationLoader.MUTATION_LOADER, ENotificationQueryParamType.CURRENT);
} catch (error) {
console.error(error);
}
};
const handleMarkAllNotificationsAsRead = async () => {
// NOTE: We are using loader to prevent continues request when we are making all the notification to read
if (loader) return;
try {
await markAllNotificationsAsRead(workspaceSlug);
} catch (error) {
console.error(error);
}
};
return (
<div className="relative flex items-center justify-center gap-2 text-body-xs-medium">
{/* mark all notifications as read*/}
<Tooltip label={t("notification.options.mark_all_as_read")} side="bottom" disabled={isMobile}>
<IconButton
size="base"
variant="ghost"
icon={loader === ENotificationLoader.MARK_ALL_AS_READY ? Spinner : CheckDoneOutline}
onClick={() => {
handleMarkAllNotificationsAsRead();
}}
/>
</Tooltip>
{/* refetch current notifications */}
<Tooltip label={t("notification.options.refresh")} side="bottom" disabled={isMobile}>
<IconButton
size="base"
variant="ghost"
icon={RefreshOutline}
className={loader === ENotificationLoader.MUTATION_LOADER ? "animate-spin" : ""}
onClick={refreshNotifications}
/>
</Tooltip>
{/* notification filters */}
<NotificationFilter />
{/* notification menu options */}
<NotificationHeaderMenuOption />
</div>
);
});

View File

@@ -0,0 +1,48 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { InboxOutline } from "@makeplane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
// local imports
import { NotificationSidebarHeaderOptions } from "./options";
type TNotificationSidebarHeader = {
workspaceSlug: string;
};
export const NotificationSidebarHeader = observer(function NotificationSidebarHeader(
props: TNotificationSidebarHeader
) {
const { workspaceSlug } = props;
const { t } = useTranslation();
if (!workspaceSlug) return <></>;
return (
<Header className="my-auto bg-surface-1">
<Header.LeftItem>
<Breadcrumbs>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t("notification.label")}
icon={<InboxOutline className="h-4 w-4 text-primary" />}
disableTooltip
/>
}
/>
</Breadcrumbs>
</Header.LeftItem>
<Header.RightItem>
<NotificationSidebarHeaderOptions workspaceSlug={workspaceSlug} />
</Header.RightItem>
</Header>
);
});

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,26 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { range } from "lodash-es";
export function NotificationsLoader() {
return (
<div className="animate-pulse divide-y divide-subtle overflow-hidden">
{range(8).map((i) => (
<div key={i} className="flex w-full items-center gap-4 p-3">
<span className="min-h-12 min-w-12 rounded-full bg-layer-1" />
<div className="flex w-full flex-col gap-2.5">
<span className="h-5 w-36 rounded-xs bg-layer-1" />
<div className="flex w-full items-center justify-between gap-2">
<span className="h-5 w-28 rounded-xs bg-layer-1" />
<span className="h-5 w-16 rounded-xs bg-layer-1" />
</div>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,234 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
// plane imports
import type { TNotification } from "@plane/types";
import {
convertMinutesToHoursMinutesString,
renderFormattedDate,
sanitizeCommentForNotification,
stripAndTruncateHTML,
} from "@plane/utils";
// components
import { LiteTextEditor } from "@/components/editor/lite-text";
import {
ADDITIONAL_NOTIFICATION_CONTENT_MAP,
renderAdditionalAction,
renderAdditionalValue,
shouldShowConnector,
} from "../../notification-card/content";
// Types
export type TNotificationFieldData = {
field: string | undefined;
newValue: string | undefined;
oldValue: string | undefined;
verb: string | undefined;
};
export type TNotificationContentDetails = {
action?: ReactNode;
value?: ReactNode;
showConnector?: boolean;
};
export type TNotificationContentHandler = (data: TNotificationFieldData) => TNotificationContentDetails | null;
export type TNotificationContentMap = {
[key: string]: TNotificationContentHandler;
};
// Base notification content map for core fields
export const BASE_NOTIFICATION_CONTENT_MAP: TNotificationContentMap = {
duplicate: ({ verb }) => ({
action:
verb === "created"
? "marked that this work item is a duplicate of"
: "marked that this work item is not a duplicate",
value: null,
showConnector: false,
}),
assignees: ({ newValue, oldValue }) => ({
action: newValue !== "" ? "added assignee" : "removed assignee",
value: newValue !== "" ? newValue : oldValue,
showConnector: false,
}),
start_date: ({ newValue }) => ({
action: newValue !== "" ? "set start date" : "removed the start date",
value: renderFormattedDate(newValue),
showConnector: false,
}),
target_date: ({ newValue }) => ({
action: newValue !== "" ? "set due date" : "removed the due date",
value: renderFormattedDate(newValue),
showConnector: false,
}),
labels: ({ newValue, oldValue }) => ({
action: newValue !== "" ? "added label" : "removed label",
value: newValue !== "" ? newValue : oldValue,
showConnector: false,
}),
parent: ({ newValue, oldValue }) => ({
action: newValue !== "" ? "added parent" : "removed parent",
value: newValue !== "" ? newValue : oldValue,
showConnector: false,
}),
relates_to: () => ({
action: "marked that this work item is related to",
value: null,
showConnector: true,
}),
comment: ({ newValue }, renderCommentBox?: boolean) => ({
action: "commented",
value: renderCommentBox ? null : sanitizeCommentForNotification(newValue),
showConnector: false,
}),
archived_at: ({ newValue }) => ({
action: newValue === "restore" ? "restored the work item" : "archived the work item",
value: null,
showConnector: false,
}),
None: () => ({
action: null,
value: "the work item and assigned it to you.",
showConnector: false,
}),
// Fields below only define value - action falls through to default handler
attachment: () => ({
action: null,
value: "the work item",
showConnector: true,
}),
description: ({ newValue }) => ({
value: stripAndTruncateHTML(newValue || "", 55),
showConnector: true,
}),
estimate_time: ({ newValue, oldValue }) => ({
value:
newValue !== ""
? convertMinutesToHoursMinutesString(Number(newValue))
: convertMinutesToHoursMinutesString(Number(oldValue)),
showConnector: true,
}),
};
// Helper to get content details from maps
const getNotificationContentDetails = (
fieldData: TNotificationFieldData,
renderCommentBox?: boolean
): TNotificationContentDetails | null => {
const { field } = fieldData;
if (!field) return null;
// Check base map first
const baseHandler = BASE_NOTIFICATION_CONTENT_MAP[field];
if (baseHandler) {
// Special case for comment field that needs renderCommentBox
if (field === "comment") {
return (baseHandler as (data: TNotificationFieldData, renderCommentBox?: boolean) => TNotificationContentDetails)(
fieldData,
renderCommentBox
);
}
return baseHandler(fieldData);
}
// Check additional map from plane-web (EE extensions)
const additionalHandler = ADDITIONAL_NOTIFICATION_CONTENT_MAP[field];
if (additionalHandler) {
return additionalHandler(fieldData);
}
return null;
};
export function NotificationContent({
notification,
workspaceId,
workspaceSlug,
projectId,
renderCommentBox = false,
}: {
notification: TNotification;
workspaceId: string;
workspaceSlug: string;
projectId: string;
renderCommentBox?: boolean;
}) {
const { data, triggered_by_details: triggeredBy } = notification;
const notificationField = data?.issue_activity.field;
const newValue = data?.issue_activity.new_value;
const oldValue = data?.issue_activity.old_value;
const verb = data?.issue_activity.verb;
const fieldData: TNotificationFieldData = {
field: notificationField,
newValue,
oldValue,
verb,
};
const renderTriggerName = () => (
<span className="font-medium text-primary">
{triggeredBy?.is_bot ? triggeredBy.first_name : triggeredBy?.display_name}{" "}
</span>
);
// Get content details from map
const contentDetails = getNotificationContentDetails(fieldData, renderCommentBox);
// Render action - use map value if defined, otherwise fall through to default handler
// Note: undefined = fall through to default, null = explicitly no action text
const renderAction = (): ReactNode => {
if (!notificationField) return "";
// Check if action is explicitly defined in map (including null)
if (contentDetails && "action" in contentDetails) return contentDetails.action;
// Fallback to default action handler for fields not in map or without action defined
return renderAdditionalAction(notificationField, verb);
};
// Render value - use map value if defined, otherwise fall through to default handler
const renderValue = (): ReactNode => {
// Check if value is explicitly defined in map
if (contentDetails && "value" in contentDetails) return contentDetails.value;
// Fallback to default value handler for fields not in map or without value defined
return renderAdditionalValue(notificationField, newValue, oldValue);
};
// Determine if connector should be shown - prefer map value, fallback to function
const showConnector =
contentDetails?.showConnector !== undefined ? contentDetails.showConnector : shouldShowConnector(notificationField);
return (
<>
{renderTriggerName()}
<span className="text-tertiary">{renderAction()} </span>
{verb !== "deleted" && (
<>
{showConnector && <span className="text-tertiary">to </span>}
<span className="font-medium text-primary">{renderValue()}</span>
{notificationField === "comment" && renderCommentBox && (
<div className="origin-left scale-75">
<LiteTextEditor
editable={false}
id=""
initialValue={newValue ?? ""}
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
projectId={projectId}
displayConfig={{
fontSize: "small-font",
}}
/>
</div>
)}
{"."}
</>
)}
</>
);
}

View File

@@ -0,0 +1,145 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import { ClockOutline } from "@makeplane/propel/icons";
// plane imports
import { Avatar } from "@makeplane/propel/components/avatar";
import { Row } from "@plane/ui";
import { cn, calculateTimeAgo, renderFormattedDate, renderFormattedTime, getFileURL } from "@plane/utils";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { useNotification } from "@/hooks/store/notifications/use-notification";
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useWorkspace } from "@/hooks/store/use-workspace";
// local imports
import { NotificationContent } from "./content";
import { NotificationOption } from "./options";
type TNotificationItem = {
workspaceSlug: string;
notificationId: string;
};
export const NotificationItem = observer(function NotificationItem(props: TNotificationItem) {
const { workspaceSlug, notificationId } = props;
// hooks
const { currentSelectedNotificationId, setCurrentSelectedNotificationId } = useWorkspaceNotifications();
const { asJson: notification, markNotificationAsRead } = useNotification(notificationId);
const { getIsIssuePeeked, setPeekIssue } = useIssueDetail();
const { getWorkspaceBySlug } = useWorkspace();
// states
const [isSnoozeStateModalOpen, setIsSnoozeStateModalOpen] = useState(false);
const [customSnoozeModal, setCustomSnoozeModal] = useState(false);
// derived values
const projectId = notification?.project || undefined;
const issueId = notification?.data?.issue?.id || undefined;
const workspace = getWorkspaceBySlug(workspaceSlug);
const notificationField = notification?.data?.issue_activity.field || undefined;
const notificationTriggeredBy = notification.triggered_by_details || undefined;
const handleNotificationIssuePeekOverview = async () => {
if (workspaceSlug && projectId && issueId && !isSnoozeStateModalOpen && !customSnoozeModal) {
setPeekIssue(undefined);
setCurrentSelectedNotificationId(notificationId);
// make the notification as read
if (notification.read_at === null) {
try {
await markNotificationAsRead(workspaceSlug);
} catch (error) {
console.error(error);
}
}
if (notification?.is_inbox_issue === false) {
if (!getIsIssuePeeked(issueId)) {
setPeekIssue({ workspaceSlug, projectId, issueId });
}
}
}
};
if (!workspaceSlug || !notificationId || !notification?.id || !notificationField || !workspace?.id || !projectId)
return <></>;
return (
<Row
className={cn(
"group relative flex cursor-pointer items-center gap-2 border-b border-subtle py-4 transition-all",
{
"bg-layer-1/30": currentSelectedNotificationId === notification?.id,
"bg-accent-primary/5": notification.read_at === null,
}
)}
onClick={handleNotificationIssuePeekOverview}
>
{notification.read_at === null && (
<div className="absolute top-[50%] left-2 h-1.5 w-1.5 flex-shrink-0 rounded-full bg-accent-primary" />
)}
<div className="relative flex w-full gap-2">
<div className="relative flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-layer-1">
{notificationTriggeredBy && (
<Avatar
alt={notificationTriggeredBy.display_name || notificationTriggeredBy?.first_name}
fallback={(notificationTriggeredBy.display_name ||
notificationTriggeredBy?.first_name)?.[0]?.toUpperCase()}
src={getFileURL(notificationTriggeredBy.avatar_url)}
size="xl"
/>
)}
</div>
<div className="-mt-2 w-full space-y-1">
<div className="relative flex h-8 items-center gap-3">
<div className="line-clamp-1 w-full truncate overflow-hidden text-body-xs-medium break-all whitespace-normal text-primary">
<NotificationContent
notification={notification}
workspaceId={workspace.id}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
</div>
<NotificationOption
workspaceSlug={workspaceSlug}
notificationId={notification?.id}
isSnoozeStateModalOpen={isSnoozeStateModalOpen}
setIsSnoozeStateModalOpen={setIsSnoozeStateModalOpen}
customSnoozeModal={customSnoozeModal}
setCustomSnoozeModal={setCustomSnoozeModal}
/>
</div>
<div className="relative flex items-center gap-3 text-caption-sm-regular text-secondary">
<div className="line-clamp-1 w-full truncate overflow-hidden break-words whitespace-normal">
{notification?.data?.issue?.identifier}-{notification?.data?.issue?.sequence_id}&nbsp;
{notification?.data?.issue?.name}
</div>
<div className="flex-shrink-0">
{notification?.snoozed_till ? (
<p className="flex flex-shrink-0 items-center justify-end gap-x-1 text-tertiary">
<ClockOutline className="h-4 w-4" />
<span>
Till {renderFormattedDate(notification.snoozed_till)},&nbsp;
{renderFormattedTime(notification.snoozed_till, "12-hour")}
</span>
</p>
) : (
<p className="mt-auto flex-shrink-0 text-tertiary">
{notification.created_at && calculateTimeAgo(notification.created_at)}
</p>
)}
</div>
</div>
</div>
</div>
</Row>
);
});

View File

@@ -0,0 +1,57 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { ArchiveOutline, RestoreOutline } from "@makeplane/propel/icons";
// plane imports
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
// store
import type { INotification } from "@/store/notifications/notification";
// local imports
import { NotificationItemOptionButton } from "./button";
type TNotificationItemArchiveOption = {
workspaceSlug: string;
notification: INotification;
};
export const NotificationItemArchiveOption = observer(function NotificationItemArchiveOption(
props: TNotificationItemArchiveOption
) {
const { workspaceSlug, notification } = props;
// hooks
const { asJson: data, archiveNotification, unArchiveNotification } = notification;
const { t } = useTranslation();
const handleNotificationUpdate = async () => {
try {
const request = data.archived_at ? unArchiveNotification : archiveNotification;
await request(workspaceSlug);
setToast({
title: data.archived_at ? t("notification.toasts.unarchived") : t("notification.toasts.archived"),
type: TOAST_TYPE.SUCCESS,
});
} catch (e) {
console.error(e);
}
};
return (
<NotificationItemOptionButton
tooltipContent={
data.archived_at ? t("notification.options.mark_unarchive") : t("notification.options.mark_archive")
}
callBack={handleNotificationUpdate}
>
{data.archived_at ? (
<RestoreOutline className="h-3 w-3 text-tertiary" />
) : (
<ArchiveOutline className="h-3 w-3 text-tertiary" />
)}
</NotificationItemOptionButton>
);
});

View File

@@ -0,0 +1,44 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import { Tooltip } from "@makeplane/propel/components/tooltip";
// helpers
import { cn } from "@plane/utils";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
type TNotificationItemOptionButton = {
tooltipContent?: string;
buttonClassName?: string;
callBack: () => void;
children: ReactNode;
};
export function NotificationItemOptionButton(props: TNotificationItemOptionButton) {
const { tooltipContent = "", buttonClassName = "", children, callBack } = props;
// hooks
const { isMobile } = usePlatformOS();
return (
<Tooltip label={tooltipContent} layout="stacked" disabled={isMobile}>
<button
type="button"
className={cn(
"relative flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-xs bg-layer-1 outline-none hover:bg-surface-2",
buttonClassName
)}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
callBack && callBack();
}}
>
{children}
</button>
</Tooltip>
);
}

View File

@@ -0,0 +1,13 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";
export * from "./read";
export * from "./archive";
export * from "./snooze";
export * from "./button";

View File

@@ -0,0 +1,51 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { ChatOutline } from "@makeplane/propel/icons";
// plane imports
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
// store
import type { INotification } from "@/store/notifications/notification";
// local imports
import { NotificationItemOptionButton } from "./button";
type TNotificationItemReadOption = {
workspaceSlug: string;
notification: INotification;
};
export const NotificationItemReadOption = observer(function NotificationItemReadOption(
props: TNotificationItemReadOption
) {
const { workspaceSlug, notification } = props;
// hooks
const { asJson: data, markNotificationAsRead, markNotificationAsUnRead } = notification;
const { t } = useTranslation();
const handleNotificationUpdate = async () => {
try {
const request = data.read_at ? markNotificationAsUnRead : markNotificationAsRead;
await request(workspaceSlug);
setToast({
title: data.read_at ? t("notification.toasts.unread") : t("notification.toasts.read"),
type: TOAST_TYPE.SUCCESS,
});
} catch (e) {
console.error(e);
}
};
return (
<NotificationItemOptionButton
tooltipContent={data.read_at ? t("notification.options.mark_unread") : t("notification.options.mark_read")}
callBack={handleNotificationUpdate}
>
<ChatOutline className="h-3 w-3 text-tertiary" />
</NotificationItemOptionButton>
);
});

View File

@@ -0,0 +1,63 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Dispatch, SetStateAction } from "react";
import { observer } from "mobx-react";
// plane imports
import { cn } from "@plane/utils";
// hooks
import { useNotification } from "@/hooks/store/notifications/use-notification";
// local imports
import { NotificationItemArchiveOption } from "./archive";
import { NotificationItemReadOption } from "./read";
import { NotificationItemSnoozeOption } from "./snooze";
type TNotificationOption = {
workspaceSlug: string;
notificationId: string;
isSnoozeStateModalOpen: boolean;
setIsSnoozeStateModalOpen: Dispatch<SetStateAction<boolean>>;
customSnoozeModal: boolean;
setCustomSnoozeModal: Dispatch<SetStateAction<boolean>>;
};
export const NotificationOption = observer(function NotificationOption(props: TNotificationOption) {
const {
workspaceSlug,
notificationId,
isSnoozeStateModalOpen,
setIsSnoozeStateModalOpen,
customSnoozeModal,
setCustomSnoozeModal,
} = props;
// hooks
const notification = useNotification(notificationId);
return (
<div
className={cn("hidden flex-shrink-0 text-body-xs-medium group-hover:block", {
block: isSnoozeStateModalOpen,
})}
>
<div className="relative flex items-center justify-center gap-2">
{/* read */}
<NotificationItemReadOption workspaceSlug={workspaceSlug} notification={notification} />
{/* archive */}
<NotificationItemArchiveOption workspaceSlug={workspaceSlug} notification={notification} />
{/* snooze notification */}
<NotificationItemSnoozeOption
workspaceSlug={workspaceSlug}
notification={notification}
setIsSnoozeStateModalOpen={setIsSnoozeStateModalOpen}
customSnoozeModal={customSnoozeModal}
setCustomSnoozeModal={setCustomSnoozeModal}
/>
</div>
</div>
);
});

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";
export * from "./modal";

View File

@@ -0,0 +1,229 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useParams } from "next/navigation";
import { useForm, Controller } from "react-hook-form";
// plane imports
import { allTimeIn30MinutesInterval12HoursFormat } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { CloseOutline } from "@makeplane/propel/icons";
import { CustomSelect, EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// components
import { getDate, cn } from "@plane/utils";
import { DateDropdown } from "@/components/dropdowns/date";
type TNotificationSnoozeModal = {
isOpen: boolean;
onClose: () => void;
onSubmit: (dateTime?: Date) => Promise<void>;
};
type FormValues = {
time: string | undefined;
date: Date | undefined;
period: "AM" | "PM";
};
const defaultValues: FormValues = {
time: undefined,
date: undefined,
period: "AM",
};
const timeStamps = allTimeIn30MinutesInterval12HoursFormat;
export function NotificationSnoozeModal(props: TNotificationSnoozeModal) {
const { isOpen, onClose, onSubmit: handleSubmitSnooze } = props;
const { workspaceSlug } = useParams();
const {
formState: { isSubmitting },
reset,
handleSubmit,
control,
watch,
setValue,
} = useForm({
defaultValues,
});
const handleClose = () => {
// This is a workaround to fix the issue of the Notification popover modal close on closing this modal
const closeTimeout = setTimeout(() => {
onClose();
clearTimeout(closeTimeout);
}, 50);
const timeout = setTimeout(() => {
reset({ ...defaultValues });
clearTimeout(timeout);
}, 500);
};
const getTimeStamp = () => {
const today = new Date();
const formDataDate = watch("date");
if (!formDataDate) return timeStamps;
const isToday = today.toDateString() === getDate(formDataDate)?.toDateString();
if (!isToday) return timeStamps;
const hours = today.getHours();
const minutes = today.getMinutes();
return timeStamps.filter((optionTime) => {
let optionHours = parseInt(optionTime.value.split(":")[0]);
const optionMinutes = parseInt(optionTime.value.split(":")[1]);
const period = watch("period");
if (period === "PM" && optionHours !== 12) optionHours += 12;
if (optionHours < hours) return false;
if (optionHours === hours && optionMinutes < minutes) return false;
return true;
});
};
const onSubmit = async (formData: FormValues) => {
if (!workspaceSlug || !formData.date || !formData.time) return;
const period = formData.period;
const time = formData.time.split(":");
const hours = parseInt(
`${period === "AM" ? time[0] : parseInt(time[0]) + 12 === 24 ? "00" : parseInt(time[0]) + 12}`
);
const minutes = parseInt(time[1]);
const dateTime: Date | undefined = getDate(formData?.date);
dateTime?.setHours(hours);
dateTime?.setMinutes(minutes);
await handleSubmitSnooze(dateTime).then(() => {
handleClose();
});
};
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.CENTER} width={EModalWidth.XXL}>
<form onSubmit={handleSubmit(onSubmit)} className="p-5">
<div className="flex items-center justify-between">
<h3 className="text-h5-medium leading-6 text-primary">Customize Snooze Time</h3>
<div>
<button type="button" onClick={handleClose}>
<CloseOutline className="h-5 w-5 text-primary" />
</button>
</div>
</div>
<div className="mt-5 flex flex-col gap-3 md:!flex-row md:items-center">
<div className="flex-1 pb-3 md:pb-0">
<h6 className="mb-2 block text-body-xs-medium text-placeholder">Pick a date</h6>
<Controller
name="date"
control={control}
rules={{ required: "Please select a date" }}
render={({ field: { value, onChange } }) => (
<DateDropdown
value={value || null}
placeholder="Select date"
onChange={(val) => {
setValue("time", undefined);
onChange(val);
}}
minDate={new Date()}
buttonVariant="border-with-text"
buttonContainerClassName="w-full text-left"
buttonClassName="border-strong px-3 py-2.5"
hideIcon
/>
)}
/>
</div>
<div className="flex-1">
<h6 className="mb-2 block text-body-xs-medium text-placeholder">Pick a time</h6>
<Controller
control={control}
name="time"
rules={{ required: "Please select a time" }}
render={({ field: { value, onChange } }) => (
<CustomSelect
value={value}
onChange={onChange}
label={
<div className="truncate">
{value ? (
<span>
{value} {watch("period").toLowerCase()}
</span>
) : (
<span className="text-body-xs-medium text-placeholder">Select a time</span>
)}
</div>
}
input
>
<div className="mb-2 flex h-9 w-full overflow-hidden rounded-xs">
<div
onClick={() => {
setValue("period", "AM");
}}
className={cn("flex h-full w-1/2 cursor-pointer items-center justify-center text-center", {
"bg-accent-primary/90 text-on-color": watch("period") === "AM",
"bg-layer-1": watch("period") !== "AM",
})}
>
AM
</div>
<div
onClick={() => {
setValue("period", "PM");
}}
className={cn("flex h-full w-1/2 cursor-pointer items-center justify-center text-center", {
"bg-accent-primary/90 text-on-color": watch("period") === "PM",
"bg-layer-1": watch("period") !== "PM",
})}
>
PM
</div>
</div>
{getTimeStamp().length > 0 ? (
getTimeStamp().map((time, index) => (
<CustomSelect.Option key={`${time}-${index}`} value={time.value}>
<div className="flex items-center">
<span className="ml-3 block truncate">{time.label}</span>
</div>
</CustomSelect.Option>
))
) : (
<p className="p-3 text-center text-secondary">No available time for this date.</p>
)}
</CustomSelect>
)}
/>
</div>
</div>
<div className="mt-5 flex items-center justify-between gap-2">
<div className="flex w-full items-center justify-end gap-2">
<Button variant="secondary" size="lg" onClick={handleClose}>
Cancel
</Button>
<Button variant="primary" size="lg" type="submit" loading={isSubmitting}>
{isSubmitting ? "Submitting..." : "Submit"}
</Button>
</div>
</div>
</form>
</ModalCore>
);
}

View File

@@ -0,0 +1,161 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { Dispatch, SetStateAction } from "react";
import { Fragment } from "react";
import { observer } from "mobx-react";
import { ClockOutline } from "@makeplane/propel/icons";
import { Popover, Transition } from "@headlessui/react";
// plane imports
import { NOTIFICATION_SNOOZE_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import { cn } from "@plane/utils";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { usePlatformOS } from "@/hooks/use-platform-os";
// store
import type { INotification } from "@/store/notifications/notification";
// local imports
import { NotificationSnoozeModal } from "./modal";
type TNotificationItemSnoozeOption = {
workspaceSlug: string;
notification: INotification;
setIsSnoozeStateModalOpen: Dispatch<SetStateAction<boolean>>;
customSnoozeModal: boolean;
setCustomSnoozeModal: Dispatch<SetStateAction<boolean>>;
};
export const NotificationItemSnoozeOption = observer(function NotificationItemSnoozeOption(
props: TNotificationItemSnoozeOption
) {
const { workspaceSlug, notification, setIsSnoozeStateModalOpen, customSnoozeModal, setCustomSnoozeModal } = props;
// hooks
const { isMobile } = usePlatformOS();
const {} = useWorkspaceNotifications();
const { t } = useTranslation();
const { asJson: data, snoozeNotification, unSnoozeNotification } = notification;
const handleNotificationSnoozeDate = async (snoozeTill: Date | undefined) => {
if (snoozeTill) {
try {
await snoozeNotification(workspaceSlug, snoozeTill);
setToast({
title: `${t("common.success")}!`,
message: t("notification.toasts.snoozed"),
type: TOAST_TYPE.SUCCESS,
});
} catch (e) {
console.error(e);
}
} else {
try {
await unSnoozeNotification(workspaceSlug);
setToast({
title: `${t("common.success")}!`,
message: t("notification.toasts.unsnoozed"),
type: TOAST_TYPE.SUCCESS,
});
} catch (e) {
console.error(e);
}
}
setCustomSnoozeModal(false);
setIsSnoozeStateModalOpen(false);
};
const handleDropdownSelect = (snoozeDate: Date | "un-snooze" | undefined) => {
if (snoozeDate === "un-snooze") {
handleNotificationSnoozeDate(undefined);
return;
}
if (snoozeDate) {
handleNotificationSnoozeDate(snoozeDate);
} else {
setCustomSnoozeModal(true);
}
};
return (
<>
<NotificationSnoozeModal
isOpen={customSnoozeModal}
onClose={() => setCustomSnoozeModal(false)}
onSubmit={handleNotificationSnoozeDate}
/>
<Popover className="relative">
{({ open }) => {
if (open) setIsSnoozeStateModalOpen(true);
else setIsSnoozeStateModalOpen(false);
return (
<>
<Tooltip
label={
data.snoozed_till ? t("notification.options.mark_unsnooze") : t("notification.options.mark_snooze")
}
disabled={isMobile}
>
<Popover.Button
className={cn(
"relative flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-xs bg-layer-1 outline-none hover:bg-surface-2",
open ? "bg-layer-1" : ""
)}
>
<ClockOutline className="h-3 w-3 text-tertiary" />
</Popover.Button>
</Tooltip>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute right-0 z-10 mt-2 min-w-44 select-none">
<div className="space-y-1 rounded-md border border-subtle bg-surface-1 p-2">
{data.snoozed_till && (
<button
className="w-full cursor-pointer rounded-xs p-1 px-2 text-left text-body-xs-medium text-secondary transition-all hover:bg-layer-1"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDropdownSelect("un-snooze");
}}
>
<div>{t("notification.options.mark_unsnooze")}</div>
</button>
)}
{NOTIFICATION_SNOOZE_OPTIONS.map((option) => (
<button
key={option.key}
className="w-full cursor-pointer rounded-xs p-1 px-2 text-left text-body-xs-medium text-secondary transition-all hover:bg-layer-1"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleDropdownSelect(option.value != undefined ? option.value() : option.value);
}}
>
<div>{t(option?.i18n_label)}</div>
</button>
))}
</div>
</Popover.Panel>
</Transition>
</>
);
}}
</Popover>
</>
);
});

View File

@@ -0,0 +1,122 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import type { TNotificationTab } from "@plane/constants";
import { NOTIFICATION_TABS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Header, Row, ERowVariant, EHeaderVariant, ContentWrapper } from "@plane/ui";
import { cn, getNumberCount } from "@plane/utils";
// components
import { CountChip } from "@/components/common/count-chip";
// hooks
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
import { useWorkspace } from "@/hooks/store/use-workspace";
// local imports
import { NotificationEmptyState } from "./empty-state";
import { AppliedFilters } from "./filters/applied-filter";
import { NotificationSidebarHeader } from "./header";
import { NotificationsLoader } from "./loader";
import { NotificationCardListRoot } from "../notification-card/root";
export const NotificationsSidebarRoot = observer(function NotificationsSidebarRoot() {
const { workspaceSlug } = useParams();
// hooks
const { getWorkspaceBySlug } = useWorkspace();
const {
currentSelectedNotificationId,
unreadNotificationsCount,
loader,
notificationIdsByWorkspaceId,
currentNotificationTab,
setCurrentNotificationTab,
} = useWorkspaceNotifications();
const { t } = useTranslation();
// derived values
const workspace = workspaceSlug ? getWorkspaceBySlug(workspaceSlug.toString()) : undefined;
const notificationIds = workspace ? notificationIdsByWorkspaceId(workspace.id) : undefined;
const handleTabClick = useCallback(
(tabValue: TNotificationTab) => {
if (currentNotificationTab !== tabValue) {
setCurrentNotificationTab(tabValue);
}
},
[currentNotificationTab, setCurrentNotificationTab]
);
if (!workspaceSlug || !workspace) return <></>;
return (
<div
className={cn(
"relative z-[10] h-full flex-shrink-0 border-0 border-subtle bg-surface-1 transition-all max-md:overflow-hidden md:border-r",
currentSelectedNotificationId ? "w-0 md:w-3/12" : "w-full md:w-3/12"
)}
>
<div className="relative flex h-full w-full flex-col">
<Row className="flex h-header flex-shrink-0 border-b border-subtle">
<NotificationSidebarHeader workspaceSlug={workspaceSlug.toString()} />
</Row>
<Header variant={EHeaderVariant.SECONDARY} className="justify-start">
{NOTIFICATION_TABS.map((tab) => (
// oxlint-disable-next-line jsx_a11y/click-events-have-key-events oxlint-disable-next-line jsx_a11y/no-static-element-interactions
<div
key={tab.value}
className="relative h-full cursor-pointer px-3"
onClick={() => handleTabClick(tab.value)}
>
<div
className={cn(
"relative flex h-full items-center justify-center gap-1 text-body-xs-medium transition-all",
{
"text-accent-primary": currentNotificationTab === tab.value,
"text-primary hover:text-secondary": currentNotificationTab !== tab.value,
}
)}
>
<div className="font-medium">{t(tab.i18n_label)}</div>
{tab.count(unreadNotificationsCount) > 0 && (
<CountChip count={getNumberCount(tab.count(unreadNotificationsCount))} />
)}
</div>
{currentNotificationTab === tab.value && (
<div className="absolute right-0 bottom-0 left-0 rounded-t-md border border-accent-strong" />
)}
</div>
))}
</Header>
{/* applied filters */}
<AppliedFilters workspaceSlug={workspaceSlug.toString()} />
{/* rendering notifications */}
{loader === "init-loader" ? (
<div className="relative h-full w-full overflow-hidden">
<NotificationsLoader />
</div>
) : (
<>
{notificationIds && notificationIds.length > 0 ? (
<ContentWrapper variant={ERowVariant.HUGGING}>
<NotificationCardListRoot workspaceSlug={workspaceSlug.toString()} workspaceId={workspace?.id} />
</ContentWrapper>
) : (
<div className="relative flex h-full w-full items-center justify-center">
<NotificationEmptyState currentNotificationTab={currentNotificationTab} />
</div>
)}
</>
)}
</div>
</div>
);
});