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
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:
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
118
apps/web/core/components/workspace-notifications/root.tsx
Normal file
118
apps/web/core/components/workspace-notifications/root.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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} />}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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" />;
|
||||
});
|
||||
@@ -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} />}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
{"."}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
{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)},
|
||||
{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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user