diff --git a/AGENTS.md b/AGENTS.md index a6924c0..be288c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,3 +78,10 @@ UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: переименован - не делать `amend` - не переписывать историю - не объединять несколько отдельных этапов в один коммит постфактум + +## Публикация фронта + +После каждой правки интерфейса агент должен: +- залить изменения на локально доступный frontend +- сообщить пользователю, по какому URL смотреть результат +- не считать этап завершенным, пока пользователь не сможет открыть измененную страницу в браузере, если этому не мешают внешние технические ограничения diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/project-shell-top-toolbar.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/project-shell-top-toolbar.tsx index c48cc14..a7f9127 100644 --- a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/project-shell-top-toolbar.tsx +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/project-shell-top-toolbar.tsx @@ -135,6 +135,7 @@ const ProjectsToolbarMenu = observer(function ProjectsToolbarMenu() { disableDrag disableDrop isLastChild={index === joinedProjectIds.length - 1} + renderInToolbarMenu /> ))} diff --git a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx index 15dc5ce..72fb2a8 100644 --- a/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx +++ b/plane-src/apps/web/app/(all)/[workspaceSlug]/(projects)/sidebar.tsx @@ -12,7 +12,6 @@ import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { SidebarWrapper } from "@/components/sidebar/sidebar-wrapper"; import { SidebarFavoritesMenu } from "@/components/workspace/sidebar/favorites/favorites-menu"; import { SidebarProjectsList } from "@/components/workspace/sidebar/projects-list"; -import { SidebarQuickActions } from "@/components/workspace/sidebar/quick-actions"; import { SidebarMenuItems } from "@/components/workspace/sidebar/sidebar-menu-items"; import { SidebarUtilityRail } from "@/components/workspace/sidebar/sidebar-utility-rail"; import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root"; @@ -38,7 +37,6 @@ export const AppSidebar = observer(function AppSidebar() { return ( } - quickActions={} footer={} > diff --git a/plane-src/apps/web/ce/components/breadcrumbs/project-feature.tsx b/plane-src/apps/web/ce/components/breadcrumbs/project-feature.tsx index 4b076a7..93f7586 100644 --- a/plane-src/apps/web/ce/components/breadcrumbs/project-feature.tsx +++ b/plane-src/apps/web/ce/components/breadcrumbs/project-feature.tsx @@ -4,23 +4,25 @@ * See the LICENSE file for details. */ -import type { ReactNode } from "react"; import { observer } from "mobx-react"; // plane imports -import type { EProjectFeatureKey } from "@plane/constants"; -import { Breadcrumbs } from "@plane/ui"; +import { EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import type { ICustomSearchSelectOption } from "@plane/types"; +import { BreadcrumbNavigationSearchDropdown } from "@plane/ui"; // components -import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; import type { TNavigationItem } from "@/components/workspace/sidebar/project-navigation"; // hooks import { useProject } from "@/hooks/store/use-project"; +import { useAppRouter } from "@/hooks/use-app-router"; +import { useUserPermissions } from "@/hooks/store/user"; // local imports import { getProjectFeatureNavigation } from "../projects/navigation/helper"; type TProjectFeatureBreadcrumbProps = { workspaceSlug: string; projectId: string; - featureKey: EProjectFeatureKey; + featureKey: TNavigationItem["key"]; isLast?: boolean; additionalNavigationItems?: TNavigationItem[]; }; @@ -29,8 +31,11 @@ export const ProjectFeatureBreadcrumb = observer(function ProjectFeatureBreadcru props: TProjectFeatureBreadcrumbProps ) { const { workspaceSlug, projectId, featureKey, isLast = false, additionalNavigationItems } = props; + const { t } = useTranslation(); + const router = useAppRouter(); // store hooks const { getPartialProjectById } = useProject(); + const { allowPermissions } = useUserPermissions(); // derived values const project = getPartialProjectById(projectId); @@ -41,26 +46,44 @@ export const ProjectFeatureBreadcrumb = observer(function ProjectFeatureBreadcru // if additional navigation items are provided, add them to the navigation items const allNavigationItems = [...(additionalNavigationItems || []), ...navigationItems]; - const currentNavigationItem = allNavigationItems.find((item) => item.key === featureKey); - const icon = currentNavigationItem?.icon as ReactNode; - const name = currentNavigationItem?.name; - const href = currentNavigationItem?.href; + const availableNavigationItems = allNavigationItems.filter( + (item) => + item.shouldRender && + allowPermissions(item.access, EUserPermissionsLevel.PROJECT, workspaceSlug.toString(), projectId.toString()) + ); + + const currentNavigationItem = availableNavigationItems.find((item) => item.key === featureKey); + const Icon = currentNavigationItem?.icon; + const name = currentNavigationItem ? t(currentNavigationItem.i18n_key) : undefined; + + const switcherOptions = availableNavigationItems.map( + (item): ICustomSearchSelectOption => ({ + value: item.key, + query: t(item.i18n_key), + content: ( +
+ + {t(item.i18n_key)} +
+ ), + }) + ); return ( - <> - {icon}} - /> + { + const nextNavigationItem = availableNavigationItems.find((item) => item.key === value); + if (nextNavigationItem?.href) { + router.push(nextNavigationItem.href); } - showSeparator={false} - isLast={isLast} - /> - + }} + title={name} + icon={Icon ? : undefined} + isLast={isLast} + openOnLabelClick + showLastChevron={false} + /> ); }); diff --git a/plane-src/apps/web/ce/components/breadcrumbs/project.tsx b/plane-src/apps/web/ce/components/breadcrumbs/project.tsx index 9793ecc..796b98c 100644 --- a/plane-src/apps/web/ce/components/breadcrumbs/project.tsx +++ b/plane-src/apps/web/ce/components/breadcrumbs/project.tsx @@ -9,21 +9,20 @@ import { Logo } from "@plane/propel/emoji-icon-picker"; import { ProjectIcon } from "@plane/propel/icons"; // plane imports import type { ICustomSearchSelectOption } from "@plane/types"; -import { BreadcrumbNavigationSearchDropdown, Breadcrumbs } from "@plane/ui"; import { SwitcherLabel } from "@/components/common/switcher-label"; // hooks import { useProject } from "@/hooks/store/use-project"; import { useAppRouter } from "@/hooks/use-app-router"; import type { TProject } from "@/plane-web/types"; +import { BreadcrumbNavigationSearchDropdown } from "@plane/ui"; type TProjectBreadcrumbProps = { workspaceSlug: string; projectId: string; - handleOnClick?: () => void; }; export const ProjectBreadcrumb = observer(function ProjectBreadcrumb(props: TProjectBreadcrumbProps) { - const { workspaceSlug, projectId, handleOnClick } = props; + const { workspaceSlug, projectId } = props; // router const router = useAppRouter(); // store hooks @@ -61,26 +60,16 @@ export const ProjectBreadcrumb = observer(function ProjectBreadcrumb(props: TPro ); return ( - <> - { - router.push(`/${workspaceSlug}/projects/${value}/issues`); - }} - title={currentProjectDetails?.name} - icon={renderIcon(currentProjectDetails)} - handleOnClick={() => { - if (handleOnClick) handleOnClick(); - else router.push(`/${workspaceSlug}/projects/${currentProjectDetails.id}/issues/`); - }} - shouldTruncate - /> - } - showSeparator={false} - /> - + { + router.push(`/${workspaceSlug}/projects/${value}/issues`); + }} + title={currentProjectDetails?.name} + icon={renderIcon(currentProjectDetails)} + openOnLabelClick + shouldTruncate + /> ); }); diff --git a/plane-src/apps/web/ce/components/issues/header.tsx b/plane-src/apps/web/ce/components/issues/header.tsx index 7b90708..fe2867e 100644 --- a/plane-src/apps/web/ce/components/issues/header.tsx +++ b/plane-src/apps/web/ce/components/issues/header.tsx @@ -10,6 +10,7 @@ import { useParams } from "next/navigation"; import { Circle } from "lucide-react"; // plane imports import { + EProjectFeatureKey, EUserPermissions, EUserPermissionsLevel, SPACE_BASE_PATH, @@ -17,12 +18,10 @@ import { WORK_ITEM_TRACKER_ELEMENTS, } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { NewTabIcon, WorkItemsIcon } from "@plane/propel/icons"; +import { NewTabIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; import { EIssuesStoreType } from "@plane/types"; import { Breadcrumbs, Header } from "@plane/ui"; -// components -import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; import { CountChip } from "@/components/common/count-chip"; import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button"; // constants @@ -37,6 +36,7 @@ import { useAppRouter } from "@/hooks/use-app-router"; import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web imports import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; +import { ProjectFeatureBreadcrumb } from "@/plane-web/components/breadcrumbs/project-feature"; export const IssuesHeader = observer(function IssuesHeader() { // router @@ -66,19 +66,14 @@ export const IssuesHeader = observer(function IssuesHeader() { return (
- -
+ +
router.back()} isLoading={loader === "init-loader"} className="flex-grow-0"> - } - isLast - /> - } + diff --git a/plane-src/apps/web/ce/components/projects/external-contours/board-root.tsx b/plane-src/apps/web/ce/components/projects/external-contours/board-root.tsx index 78c75c1..aabc101 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/board-root.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/board-root.tsx @@ -21,7 +21,7 @@ export const ExternalContoursBoardRoot = observer(function ExternalContoursBoard const { hasAnyItems, isFiltering, loader } = useProjectExternalContoursBoard(); return ( -
+
{loader === "init-loading" && !hasAnyItems ? (
{t("loading")}...
) : ( diff --git a/plane-src/apps/web/ce/components/projects/external-contours/header.tsx b/plane-src/apps/web/ce/components/projects/external-contours/header.tsx index daac7eb..1a3d9f8 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/header.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/header.tsx @@ -10,15 +10,14 @@ import { useParams } from "next/navigation"; import { RefreshCcw } from "lucide-react"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { TransferIcon } from "@plane/propel/icons"; import { Breadcrumbs, Header } from "@plane/ui"; -import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button"; import { FiltersToggle } from "@/components/rich-filters/filters-toggle"; import { useProject } from "@/hooks/store/use-project"; import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours"; import { useUserPermissions } from "@/hooks/store/user"; import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; +import { ProjectFeatureBreadcrumb } from "@/plane-web/components/breadcrumbs/project-feature"; import { useExternalContoursFilter } from "./filters/provider"; import { ExternalContourCreateModalRoot } from "./create-modal"; @@ -38,19 +37,14 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo return (
- -
+ +
- } - isLast - /> - } + diff --git a/plane-src/apps/web/ce/components/projects/external-contours/root.tsx b/plane-src/apps/web/ce/components/projects/external-contours/root.tsx index d4327c9..aa9600b 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/root.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/root.tsx @@ -88,9 +88,13 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props return (
- {filter && } + {filter && ( +
+ +
+ )} -
+
{inboxIssueId && ( - -
+ +
- } - isLast - /> - } + @@ -72,15 +67,16 @@ export const ProjectInboxHeader = observer(function ProjectInboxHeader() { {currentProjectDetails?.inbox_view && workspaceSlug && projectId && isAuthorized ? (
+ setCreateIssueModal(false)} /> - +
) : ( <> diff --git a/plane-src/apps/web/core/components/inbox/content/inbox-issue-header.tsx b/plane-src/apps/web/core/components/inbox/content/inbox-issue-header.tsx index d744f25..4e36a3e 100644 --- a/plane-src/apps/web/core/components/inbox/content/inbox-issue-header.tsx +++ b/plane-src/apps/web/core/components/inbox/content/inbox-issue-header.tsx @@ -6,12 +6,12 @@ import { useCallback, useEffect, useState } from "react"; import { observer } from "mobx-react"; -import { Clock, FileStack, MoreHorizontal, MoveRight } from "lucide-react"; +import { Clock, FileStack, MoreHorizontal } from "lucide-react"; // plane imports import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { Button } from "@plane/propel/button"; -import { IconButton, getIconButtonStyling } from "@plane/propel/icon-button"; +import { IconButton } from "@plane/propel/icon-button"; import { LinkIcon, CopyIcon, @@ -25,11 +25,11 @@ import { import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import type { TNameDescriptionLoader } from "@plane/types"; import { EInboxIssueStatus } from "@plane/types"; -import { ControlLink, CustomMenu, Row } from "@plane/ui"; +import { ControlLink, CustomMenu } from "@plane/ui"; import { copyUrlToClipboard, findHowManyDaysLeft, generateWorkItemLink } from "@plane/utils"; // components import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal"; -import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status"; +import { IssuePeekOverviewHeader, type TPeekModes } from "@/components/issues/peek-overview/header"; // hooks import { useProject } from "@/hooks/store/use-project"; import { useProjectInbox } from "@/hooks/store/use-project-inbox"; @@ -54,6 +54,10 @@ type TInboxIssueActionsHeader = { setIsMobileSidebar: (value: boolean) => void; isNotificationEmbed: boolean; embedRemoveCurrentNotification?: () => void; + peekMode: TPeekModes; + setPeekMode: (value: TPeekModes) => void; + removeRoutePeekId: () => void; + disabled: boolean; }; export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader(props: TInboxIssueActionsHeader) { @@ -66,6 +70,10 @@ export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader setIsMobileSidebar, isNotificationEmbed = false, embedRemoveCurrentNotification, + peekMode, + setPeekMode, + removeRoutePeekId, + disabled, } = props; // states const [isSnoozeDateModalOpen, setIsSnoozeDateModalOpen] = useState(false); @@ -74,7 +82,7 @@ export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader const [declineIssueModal, setDeclineIssueModal] = useState(false); const [deleteIssueModal, setDeleteIssueModal] = useState(false); // store - const { currentTab, deleteInboxIssue, filteredInboxIssueIds } = useProjectInbox(); + const { deleteInboxIssue, filteredInboxIssueIds } = useProjectInbox(); const { data: currentUser } = useUser(); const { allowPermissions } = useUserPermissions(); const { getPartialProjectById } = useProject(); @@ -124,11 +132,8 @@ export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader const handleRedirection = (nextOrPreviousIssueId: string | undefined) => { if (!isNotificationEmbed) { - if (nextOrPreviousIssueId) - router.push( - `/${workspaceSlug}/projects/${projectId}/intake?currentTab=${currentTab}&inboxIssueId=${nextOrPreviousIssueId}` - ); - else router.push(`/${workspaceSlug}/projects/${projectId}/intake?currentTab=${currentTab}`); + if (nextOrPreviousIssueId) router.push(`/${workspaceSlug}/projects/${projectId}/intake?inboxIssueId=${nextOrPreviousIssueId}`); + else router.push(`/${workspaceSlug}/projects/${projectId}/intake`); } }; @@ -241,6 +246,169 @@ export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader sequenceId: issue?.sequence_id, }); + const metaSlot = ( +
+ {issue?.project_id && issue.sequence_id && ( +
+ + {getProjectById(issue.project_id)?.identifier}-{issue.sequence_id} + +
+ )} + +
+ ); + + const actionSlot = ( +
+ {!isNotificationEmbed && ( +
+ handleInboxIssueNavigation("prev")} + className="nodedc-external-icon-button" + /> + handleInboxIssueNavigation("next")} + className="nodedc-external-icon-button" + /> +
+ )} + + {canMarkAsAccepted && ( + + )} + + {canMarkAsDeclined && ( + + )} + + {isAcceptedOrDeclined ? ( + <> + + router.push(workItemLink)} target="_self"> + + + + ) : ( + <> + {isAllowed && ( + } + customButtonClassName="nodedc-external-icon-button" + placement="bottom-start" + menuItemsClassName="z-[760]" + > + {canMarkAsAccepted && ( + + handleActionWithPermission( + isProjectAdmin, + handleIssueSnoozeAction, + t("inbox_issue.errors.snooze_permission") + ) + } + > +
+ + {inboxIssue?.snoozed_till && numberOfDaysLeft && numberOfDaysLeft > 0 + ? t("inbox_issue.actions.unsnooze") + : t("inbox_issue.actions.snooze")} +
+
+ )} + {canMarkAsDuplicate && ( + + handleActionWithPermission( + isProjectAdmin, + () => setSelectDuplicateIssue(true), + t("inbox_issue.errors.duplicate_permission") + ) + } + > +
+ + {t("inbox_issue.actions.mark_as_duplicate")} +
+
+ )} + handleCopyIssueLink(workItemLink)}> +
+ + {t("inbox_issue.actions.copy")} +
+
+ {canDelete && ( + setDeleteIssueModal(true)}> +
+ + {t("inbox_issue.actions.delete")} +
+
+ )} +
+ )} + + )} +
+ ); + return ( <> <> @@ -286,158 +454,31 @@ export const InboxIssueActionsHeader = observer(function InboxIssueActionsHeader /> - -
- {isNotificationEmbed && ( - - )} - {issue?.project_id && issue.sequence_id && ( -

- {getProjectById(issue.project_id)?.identifier}-{issue.sequence_id} -

- )} - -
- -
-
- -
- {!isNotificationEmbed && ( -
- handleInboxIssueNavigation("prev")} - /> - handleInboxIssueNavigation("next")} - /> -
- )} - -
- {canMarkAsAccepted && ( - - )} - - {canMarkAsDeclined && ( - - )} - - {isAcceptedOrDeclined ? ( -
- - router.push(workItemLink)} target="_self"> - - -
- ) : ( - <> - {isAllowed && ( - } - customButtonClassName={getIconButtonStyling("secondary", "lg")} - placement="bottom-start" - > - {canMarkAsAccepted && ( - - handleActionWithPermission( - isProjectAdmin, - handleIssueSnoozeAction, - t("inbox_issue.errors.snooze_permission") - ) - } - > -
- - {inboxIssue?.snoozed_till && numberOfDaysLeft && numberOfDaysLeft > 0 - ? t("inbox_issue.actions.unsnooze") - : t("inbox_issue.actions.snooze")} -
-
- )} - {canMarkAsDuplicate && ( - - handleActionWithPermission( - isProjectAdmin, - () => setSelectDuplicateIssue(true), - t("inbox_issue.errors.duplicate_permission") - ) - } - > -
- - {t("inbox_issue.actions.mark_as_duplicate")} -
-
- )} - handleCopyIssueLink(workItemLink)}> -
- - {t("inbox_issue.actions.copy")} -
-
- {canDelete && ( - setDeleteIssueModal(true)}> -
- - {t("inbox_issue.actions.delete")} -
-
- )} -
- )} - - )} -
-
-
+
+ undefined} + toggleArchiveIssueModal={() => undefined} + toggleDuplicateIssueModal={() => undefined} + toggleEditIssueModal={() => undefined} + handleRestoreIssue={async () => undefined} + isSubmitting={isSubmitting} + metaSlot={metaSlot} + actionSlot={actionSlot} + showLayoutSwitcher + showSubscription={false} + showCopyLink={false} + showQuickActions={false} + /> +
; + if (!issue || !issue.id) return <>; + + const selectedAssigneeIds = issue.assignee_ids ?? []; + const selectedLabelsCount = issue.label_ids?.length ?? 0; + const singleLabelDetails = + selectedLabelsCount === 1 && issue.label_ids?.[0] ? getLabelById(issue.label_ids[0]) : undefined; const duplicateWorkItemLink = generateWorkItemLink({ workspaceSlug: workspaceSlug?.toString(), projectId, @@ -60,157 +67,138 @@ export const InboxIssueContentProperties = observer(function InboxIssueContentPr projectIdentifier: currentProjectDetails?.identifier, sequenceId: duplicateIssueDetails?.sequence_id, }); - const DropdownComponent = isIntakeAccepted ? StateDropdown : IntakeStateDropdown; return ( -
-
-
{t("properties")}
-
-
- {/* Intake State */} -
-
- - {t("state")} -
- {issue?.state_id && ( - {}} - projectId={projectId?.toString() ?? ""} - disabled - buttonVariant="transparent-with-text" - className="group w-3/5 flex-grow" - buttonContainerClassName="w-full text-left" - buttonClassName="text-13" - dropdownArrow - dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline" - /> - )} -
- {/* Assignee */} -
-
- - {t("assignees")} -
- - issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { assignee_ids: val }) - } - disabled={!isEditable} - projectId={projectId?.toString() ?? ""} - placeholder={t("assignee")} - multiple - buttonVariant={ - (issue?.assignee_ids || [])?.length > 0 ? "transparent-without-text" : "transparent-with-text" - } - className="group w-3/5 flex-grow" - buttonContainerClassName="w-full text-left" - buttonClassName={`text-13 justify-between ${ - (issue?.assignee_ids || [])?.length > 0 ? "" : "text-placeholder" - }`} - hideIcon={issue.assignee_ids?.length === 0} - dropdownArrow - dropdownArrowClassName="h-3.5 w-3.5 hidden group-hover:inline" - /> -
- {/* Priority */} -
-
- - {t("priority")} -
- - issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { priority: val }) - } - disabled={!isEditable} - buttonVariant="border-with-text" - className="w-3/5 flex-grow rounded-sm px-2 hover:bg-layer-1" - buttonContainerClassName="w-full text-left" - buttonClassName="w-min h-auto whitespace-nowrap" - /> -
-
-
-
-
- {/* Due Date */} -
-
- - {t("due_date")} -
- - issue?.id && - issueOperations.update(workspaceSlug, projectId, issue?.id, { - target_date: val ? renderFormattedPayloadDate(val) : null, - }) - } - minDate={minDate ?? undefined} - disabled={!isEditable} - buttonVariant="transparent-with-text" - className="group w-3/5 flex-grow" - buttonContainerClassName="w-full text-left" - buttonClassName={`text-13 ${issue?.target_date ? "" : "text-placeholder"}`} - hideIcon - clearIconClassName="h-3 w-3 hidden group-hover:inline" - /> -
- {/* Labels */} -
-
- - {t("labels")} -
-
- {issue?.id && ( - - issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { label_ids: val }) - } - /> - )} -
-
+
+
{t("common.properties")}
+
+ + {isIntakeAccepted ? ( + issue.id && issueOperations.update(workspaceSlug, projectId, issue.id, { state_id: val })} + projectId={projectId} + disabled={!isEditable} + buttonVariant="transparent-with-text" + className="group w-full grow" + buttonContainerClassName={CONTROL_CONTAINER_CLASS_NAME} + buttonClassName={cn(CONTROL_CLASS_NAME, !issue.state_id && "text-placeholder")} + dropdownArrow + dropdownArrowClassName="hidden h-3.5 w-3.5 group-hover:inline" + /> + ) : ( + issue.id && issueOperations.update(workspaceSlug, projectId, issue.id, { state_id: val })} + projectId={projectId} + disabled={!isEditable} + buttonVariant="transparent-with-text" + className="group w-full grow" + buttonContainerClassName={CONTROL_CONTAINER_CLASS_NAME} + buttonClassName={cn(CONTROL_CLASS_NAME, !issue.state_id && "text-placeholder")} + dropdownArrow + dropdownArrowClassName="hidden h-3.5 w-3.5 group-hover:inline" + /> + )} + - {/* duplicate to*/} - {duplicateIssueDetails && ( -
-
- - {t("issues.properties.duplicate_of")} -
- - { - router.push(duplicateWorkItemLink); - }} - target="_self" - > - - - {`${currentProjectDetails?.identifier}-${duplicateIssueDetails?.sequence_id}`} - - - -
+ + + issue.id && issueOperations.update(workspaceSlug, projectId, issue.id, { assignee_ids: val }) + } + disabled={!isEditable} + projectId={projectId?.toString() ?? ""} + multiple + buttonVariant={selectedAssigneeIds.length > 1 ? "transparent-without-text" : "transparent-with-text"} + className="group w-full grow" + buttonContainerClassName={CONTROL_CONTAINER_CLASS_NAME} + buttonClassName={cn( + "justify-between text-body-xs-medium", + selectedAssigneeIds.length === 0 && "text-placeholder" )} -
-
+ hideIcon={selectedAssigneeIds.length === 0} + dropdownArrow + dropdownArrowClassName="hidden h-3.5 w-3.5 group-hover:inline" + placeholder={t("issue.add.assignee")} + /> + + + + issue.id && issueOperations.update(workspaceSlug, projectId, issue.id, { priority: val })} + disabled={!isEditable} + buttonVariant="transparent-with-text" + className="w-full grow rounded-sm" + buttonContainerClassName={CONTROL_CONTAINER_CLASS_NAME} + buttonClassName={cn( + "whitespace-nowrap text-body-xs-medium [&_svg]:size-3.5", + (!issue.priority || issue.priority === "none") && "text-placeholder" + )} + /> + + + + + issue.id && + issueOperations.update(workspaceSlug, projectId, issue.id, { + target_date: val ? renderFormattedPayloadDate(val) : null, + }) + } + minDate={minDate ?? undefined} + disabled={!isEditable} + buttonVariant="transparent-with-text" + className="group w-full grow" + buttonContainerClassName={CONTROL_CONTAINER_CLASS_NAME} + buttonClassName={cn(CONTROL_CLASS_NAME, !issue.target_date && "text-placeholder")} + hideIcon + clearIconClassName="hidden h-3 w-3 group-hover:inline text-primary" + /> + + + + issue.id && issueOperations.update(workspaceSlug, projectId, issue.id, { label_ids: labelIds })} + projectId={projectId} + disabled={!isEditable} + placement="top-start" + rootClassName="w-full grow overflow-visible" + buttonContainerClassName="w-full text-left h-7.5 rounded-full border-0 bg-transparent shadow-none outline-none" + label={ +
+ + 0 ? "truncate text-primary" : "truncate text-placeholder"}> + {selectedLabelsCount > 0 + ? selectedLabelsCount === 1 + ? singleLabelDetails?.name ?? t("common.labels") + : `${selectedLabelsCount} ${t("common.labels").toLocaleLowerCase()}` + : t("common.labels")} + +
+ } + /> +
+ + {duplicateIssueDetails && ( + + { + router.push(duplicateWorkItemLink); + }} + target="_self" + className="flex min-h-7.5 items-center rounded-full px-2 py-1 text-body-xs-medium" + > + {`${currentProjectDetails?.identifier}-${duplicateIssueDetails.sequence_id}`} + + + )}
); diff --git a/plane-src/apps/web/core/components/inbox/content/issue-root.tsx b/plane-src/apps/web/core/components/inbox/content/issue-root.tsx index 3e1fe6b..916f474 100644 --- a/plane-src/apps/web/core/components/inbox/content/issue-root.tsx +++ b/plane-src/apps/web/core/components/inbox/content/issue-root.tsx @@ -5,13 +5,12 @@ */ import type { Dispatch, SetStateAction } from "react"; -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useRef } from "react"; import { observer } from "mobx-react"; import { useTranslation } from "@plane/i18n"; // plane imports import type { EditorRefApi } from "@plane/editor"; -import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import type { TIssue, TNameDescriptionLoader } from "@plane/types"; +import type { TNameDescriptionLoader } from "@plane/types"; import { EFileAssetType, EInboxIssueSource, EInboxIssueStatus } from "@plane/types"; import { getTextContent } from "@plane/utils"; // components @@ -22,6 +21,7 @@ import { IssueAttachmentRoot } from "@/components/issues/attachment"; import type { TIssueOperations } from "@/components/issues/issue-detail"; import { IssueActivity } from "@/components/issues/issue-detail/issue-activity"; import { IssueReaction } from "@/components/issues/issue-detail/reactions"; +import type { TPeekModes } from "@/components/issues/peek-overview/header"; import { IssueTitleInput } from "@/components/issues/title-input"; // hooks import { useIssueDetail } from "@/hooks/store/use-issue-detail"; @@ -49,10 +49,13 @@ type Props = { isEditable: boolean; isSubmitting: TNameDescriptionLoader; setIsSubmitting: Dispatch>; + issueOperations: TIssueOperations; + peekMode: TPeekModes; }; export const InboxIssueMainContent = observer(function InboxIssueMainContent(props: Props) { - const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting } = props; + const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting, issueOperations, peekMode } = + props; const { t } = useTranslation(); // refs const editorRef = useRef(null); @@ -61,7 +64,9 @@ export const InboxIssueMainContent = observer(function InboxIssueMainContent(pro const { getUserDetails } = useMember(); const { loader } = useProjectInbox(); const { getProjectById } = useProject(); - const { removeIssue, archiveIssue } = useIssueDetail(); + const { + issue: { getIssueById }, + } = useIssueDetail(); // reload confirmation const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); @@ -77,7 +82,7 @@ export const InboxIssueMainContent = observer(function InboxIssueMainContent(pro }, [isSubmitting, setShowAlert, setIsSubmitting]); // derived values - const issue = inboxIssue.issue; + const issue = (inboxIssue.issue.id ? getIssueById(inboxIssue.issue.id) : undefined) ?? inboxIssue.issue; const projectDetails = issue?.project_id ? getProjectById(issue?.project_id) : undefined; const isIntakeAccepted = inboxIssue.status === EInboxIssueStatus.ACCEPTED; @@ -93,164 +98,153 @@ export const InboxIssueMainContent = observer(function InboxIssueMainContent(pro } ); - const issueOperations: TIssueOperations = useMemo( - () => ({ - fetch: async (_workspaceSlug: string, _projectId: string, _issueId: string) => { - return; - }, - - remove: async (_workspaceSlug: string, _projectId: string, _issueId: string) => { - try { - await removeIssue(workspaceSlug, projectId, _issueId); - setToast({ - title: t("success"), - type: TOAST_TYPE.SUCCESS, - message: t("inbox_issue.modals.delete.success"), - }); - } catch (error) { - console.log("Error in deleting work item:", error); - setToast({ - title: t("error"), - type: TOAST_TYPE.ERROR, - message: t("something_went_wrong_please_try_again"), - }); - } - }, - update: async (_workspaceSlug: string, _projectId: string, _issueId: string, data: Partial) => { - try { - await inboxIssue.updateIssue(data); - } catch (_error) { - setToast({ - title: t("error"), - type: TOAST_TYPE.ERROR, - message: t("issue_could_not_be_updated"), - }); - } - }, - archive: async (workspaceSlug: string, projectId: string, issueId: string) => { - try { - await archiveIssue(workspaceSlug, projectId, issueId); - } catch (error) { - console.error("Error in archiving issue:", error); - } - }, - }), - [inboxIssue] - ); - if (!issue) return <>; if (!issue?.project_id || !issue?.id) return <>; - return ( - <> -
- {duplicateIssues.length > 0 && ( - - )} - setIsSubmitting(value)} - issueOperations={issueOperations} + const detailsContent = ( +
+ setIsSubmitting(value)} + issueOperations={issueOperations} + disabled={!isEditable} + value={issue.name} + containerClassName="-ml-3" + /> + + {loader === "issue-loading" || issue.description_html === undefined ? ( + + ) : ( +

"} + key={issue.id} + onSubmit={async (value, isMigrationUpdate) => { + if (!issue.id || !issue.project_id) return; + await issueOperations.update(workspaceSlug, issue.project_id, issue.id, { + description_html: value.description_html, + ...(isMigrationUpdate ? { skip_activity: "true" } : {}), + }); + }} + projectId={issue.project_id} + setIsSubmitting={(value) => setIsSubmitting(value)} + workspaceSlug={workspaceSlug} /> + )} - {loader === "issue-loading" || issue.description_html === undefined ? ( - - ) : ( -

"} - key={issue.id} - onSubmit={async (value, isMigrationUpdate) => { - if (!issue.id || !issue.project_id) return; - await issueOperations.update(workspaceSlug, issue.project_id, issue.id, { - description_html: value.description_html, - ...(isMigrationUpdate ? { skip_activity: "true" } : {}), - }); +
+ {currentUser && ( + + )} + {isEditable && ( + setIsSubmitting(value)} + fetchHandlers={{ + listDescriptionVersions: (issueId) => + intakeWorkItemVersionService.listDescriptionVersions(workspaceSlug, projectId, issueId), + retrieveDescriptionVersion: (issueId, versionId) => + intakeWorkItemVersionService.retrieveDescriptionVersion(workspaceSlug, projectId, issueId, versionId), + }} + handleRestore={(descriptionHTML) => editorRef.current?.setEditorValue(descriptionHTML, true)} + projectId={projectId} workspaceSlug={workspaceSlug} /> )} +
+
+ ); -
- {currentUser && ( - - )} - {isEditable && ( - - intakeWorkItemVersionService.listDescriptionVersions(workspaceSlug, projectId, issueId), - retrieveDescriptionVersion: (issueId, versionId) => - intakeWorkItemVersionService.retrieveDescriptionVersion(workspaceSlug, projectId, issueId, versionId), - }} - handleRestore={(descriptionHTML) => editorRef.current?.setEditorValue(descriptionHTML, true)} - projectId={projectId} + const attachmentContent = ( +
+ +
+ ); + + const propertiesContent = ( + + ); + + const activityContent = ( + + ); + + if (peekMode === "full-screen") { + return ( +
+
+ {duplicateIssues.length > 0 && ( + )} + +
+ {detailsContent} + {attachmentContent} + {activityContent} +
+
+ +
+ {propertiesContent}
+ ); + } -
- + {duplicateIssues.length > 0 && ( + -
- -
- -
+ )} -
- -
- + {detailsContent} + {attachmentContent} + {propertiesContent} + {activityContent} +
); }); diff --git a/plane-src/apps/web/core/components/inbox/content/root.tsx b/plane-src/apps/web/core/components/inbox/content/root.tsx index 995d83b..046f411 100644 --- a/plane-src/apps/web/core/components/inbox/content/root.tsx +++ b/plane-src/apps/web/core/components/inbox/content/root.tsx @@ -4,17 +4,21 @@ * See the LICENSE file for details. */ -import { useEffect, useState } from "react"; +import { useEffect, useMemo } from "react"; import { observer } from "mobx-react"; import useSWR from "swr"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; -import type { TNameDescriptionLoader } from "@plane/types"; -// components -import { ContentWrapper } from "@plane/ui"; +import { useTranslation } from "@plane/i18n"; +import { TOAST_TYPE, setToast } from "@plane/propel/toast"; +import type { TIssue } from "@plane/types"; // hooks +import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useProjectInbox } from "@/hooks/store/use-project-inbox"; import { useUser, useUserPermissions } from "@/hooks/store/user"; import { useAppRouter } from "@/hooks/use-app-router"; +import { IssueView } from "@/components/issues/peek-overview/view"; +import type { TIssueOperations } from "@/components/issues/issue-detail"; +import { EInboxIssueStatus } from "@plane/types"; // local imports import { InboxIssueActionsHeader } from "./inbox-issue-header"; import { InboxIssueMainContent } from "./issue-root"; @@ -41,11 +45,11 @@ export const InboxContentRoot = observer(function InboxContentRoot(props: TInbox } = props; /// router const router = useAppRouter(); - // states - const [isSubmitting, setIsSubmitting] = useState("saved"); + const { t } = useTranslation(); // hooks const { data: currentUser } = useUser(); - const { currentTab, fetchInboxIssueById, getIssueInboxByIssueId, getIsIssueAvailable } = useProjectInbox(); + const { fetchInboxIssueById, getIssueInboxByIssueId, getIsIssueAvailable } = useProjectInbox(); + const { removeIssue, archiveIssue } = useIssueDetail(); const inboxIssue = getIssueInboxByIssueId(inboxIssueId); const { allowPermissions, getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions(); @@ -54,7 +58,7 @@ export const InboxContentRoot = observer(function InboxContentRoot(props: TInbox useEffect(() => { if (!isIssueAvailable && inboxIssueId && !isNotificationEmbed) { - router.replace(`/${workspaceSlug}/projects/${projectId}/intake?currentTab=${currentTab}`); + router.replace(`/${workspaceSlug}/projects/${projectId}/intake`); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isIssueAvailable, isNotificationEmbed]); @@ -83,11 +87,77 @@ export const InboxContentRoot = observer(function InboxContentRoot(props: TInbox if (!inboxIssue) return <>; const isIssueDisabled = [-1, 1, 2].includes(inboxIssue.status); + const canEditIssue = isEditable && !isIssueDisabled && !readOnly; + + const issueOperations: TIssueOperations = useMemo( + () => ({ + fetch: async () => { + return; + }, + remove: async (_workspaceSlug: string, _projectId: string, _issueId: string) => { + try { + await removeIssue(workspaceSlug, projectId, _issueId); + setToast({ + title: t("success"), + type: TOAST_TYPE.SUCCESS, + message: t("inbox_issue.modals.delete.success"), + }); + } catch (error) { + console.error("Error in deleting work item:", error); + setToast({ + title: t("error"), + type: TOAST_TYPE.ERROR, + message: t("something_went_wrong_please_try_again"), + }); + } + }, + update: async (_workspaceSlug: string, _projectId: string, _issueId: string, data: Partial) => { + try { + if (inboxIssue.status === EInboxIssueStatus.ACCEPTED) { + await inboxIssue.updateProjectIssue(data); + } else { + await inboxIssue.updateIssue(data); + } + } catch (_error) { + setToast({ + title: t("error"), + type: TOAST_TYPE.ERROR, + message: t("issue_could_not_be_updated"), + }); + } + }, + archive: async (_workspaceSlug: string, _projectId: string, _issueId: string) => { + try { + await archiveIssue(workspaceSlug, projectId, _issueId); + } catch (error) { + console.error("Error in archiving issue:", error); + } + }, + }), + [archiveIssue, inboxIssue, projectId, removeIssue, t, workspaceSlug] + ); + + const handleCloseIssueView = () => { + if (isNotificationEmbed) { + embedRemoveCurrentNotification?.(); + return; + } + + router.push(`/${workspaceSlug}/projects/${projectId}/intake`); + }; return ( - <> -
-
+
+ ( -
- + )} + renderContent={({ peekMode, isSubmitting, setIsSubmitting }) => ( - -
- + )} + embedRemoveCurrentNotification={handleCloseIssueView} + /> +
); }); diff --git a/plane-src/apps/web/core/components/inbox/empty-state.tsx b/plane-src/apps/web/core/components/inbox/empty-state.tsx new file mode 100644 index 0000000..a6a384e --- /dev/null +++ b/plane-src/apps/web/core/components/inbox/empty-state.tsx @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { ViewVerticalStackIllustration, WorkItemVerticalStackIllustration } from "@plane/propel/empty-state"; +import { cn } from "@plane/utils"; + +type Props = { + title: string; + description?: string; + compact?: boolean; + kind?: "list" | "detail"; + className?: string; +}; + +export const InboxEmptyState = (props: Props) => { + const { title, description, compact = false, kind = "list", className } = props; + const Illustration = kind === "detail" ? ViewVerticalStackIllustration : WorkItemVerticalStackIllustration; + + return ( +
+
+ +
+
+

{title}

+ {description &&

{description}

} +
+
+ ); +}; diff --git a/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/chip.tsx b/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/chip.tsx new file mode 100644 index 0000000..edd2be2 --- /dev/null +++ b/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/chip.tsx @@ -0,0 +1,59 @@ +/** + * 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 { CloseIcon } from "@plane/propel/icons"; + +type TInboxAppliedFilterChipValue = { + key: string; + label: string; + icon?: ReactNode; + onRemove?: () => void; +}; + +type TInboxAppliedFilterChipProps = { + title: string; + values: TInboxAppliedFilterChipValue[]; + onClear?: () => void; +}; + +export const InboxAppliedFilterChip = (props: TInboxAppliedFilterChipProps) => { + const { title, values, onClear } = props; + + if (values.length === 0) return null; + + return ( +
+ {title} + {values.map((value) => ( +
+ {value.icon ? {value.icon} : null} + {value.label} + {value.onRemove ? ( + + ) : null} +
+ ))} + {onClear ? ( + + ) : null} +
+ ); +}; diff --git a/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/date.tsx b/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/date.tsx index 53cf709..dc0ec5f 100644 --- a/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/date.tsx +++ b/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/date.tsx @@ -6,14 +6,13 @@ import { observer } from "mobx-react"; import { PAST_DURATION_FILTER_OPTIONS } from "@plane/constants"; -import { CloseIcon } from "@plane/propel/icons"; import type { TInboxIssueFilterDateKeys } from "@plane/types"; // helpers -import { Tag } from "@plane/ui"; import { renderFormattedDate } from "@plane/utils"; // constants // hooks import { useProjectInbox } from "@/hooks/store/use-project-inbox"; +import { InboxAppliedFilterChip } from "./chip"; type InboxIssueAppliedFiltersDate = { filterKey: TInboxIssueFilterDateKeys; @@ -44,31 +43,17 @@ export const InboxIssueAppliedFiltersDate = observer(function InboxIssueAppliedF const clearFilter = () => handleInboxIssueFilters(filterKey, undefined); if (filteredValues.length === 0) return <>; - return ( - -
{label}
- {filteredValues.map((value) => { - const optionDetail = currentOptionDetail(value); - if (!optionDetail) return <>; - return ( -
-
{optionDetail?.name}
-
handleInboxIssueFilters(filterKey, handleFilterValue(optionDetail?.value))} - > - -
-
- ); - })} -
- -
-
+ const values = filteredValues.map((value) => { + const optionDetail = currentOptionDetail(value); + return { + key: value, + label: optionDetail.name, + onRemove: () => handleInboxIssueFilters(filterKey, handleFilterValue(optionDetail.value)), + }; + }); + + return ( + ); }); diff --git a/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/label.tsx b/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/label.tsx index 1fd33cb..8c3edc8 100644 --- a/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/label.tsx +++ b/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/label.tsx @@ -6,16 +6,17 @@ import { observer } from "mobx-react"; // hooks -import { CloseIcon } from "@plane/propel/icons"; -import { Tag } from "@plane/ui"; +import { useTranslation } from "@plane/i18n"; import { useLabel } from "@/hooks/store/use-label"; import { useProjectInbox } from "@/hooks/store/use-project-inbox"; +import { InboxAppliedFilterChip } from "./chip"; function LabelIcons({ color }: { color: string }) { return ; } export const InboxIssueAppliedFiltersLabel = observer(function InboxIssueAppliedFiltersLabel() { + const { t } = useTranslation(); // hooks const { inboxFilters, handleInboxIssueFilters } = useProjectInbox(); const { getLabelById } = useLabel(); @@ -29,34 +30,22 @@ export const InboxIssueAppliedFiltersLabel = observer(function InboxIssueApplied const clearFilter = () => handleInboxIssueFilters("labels", undefined); if (filteredValues.length === 0) return <>; - return ( - -
Label
- {filteredValues.map((value) => { - const optionDetail = currentOptionDetail(value); - if (!optionDetail) return <>; - return ( -
-
- -
-
{optionDetail?.name}
-
handleInboxIssueFilters("labels", handleFilterValue(value))} - > - -
-
- ); - })} -
- -
-
+ const values = filteredValues + .map((value) => { + const optionDetail = currentOptionDetail(value); + if (!optionDetail) return undefined; + + return { + key: value, + label: optionDetail.name, + icon: , + onRemove: () => handleInboxIssueFilters("labels", handleFilterValue(value)), + }; + }) + .filter((value): value is NonNullable => !!value); + + return ( + ); }); diff --git a/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/member.tsx b/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/member.tsx index 4148f4a..fcb3c76 100644 --- a/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/member.tsx +++ b/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/member.tsx @@ -7,15 +7,15 @@ import { observer } from "mobx-react"; // plane types -import { CloseIcon } from "@plane/propel/icons"; import type { TInboxIssueFilterMemberKeys } from "@plane/types"; // plane ui -import { Avatar, Tag } from "@plane/ui"; +import { Avatar } from "@plane/ui"; // helpers import { getFileURL } from "@plane/utils"; // hooks import { useMember } from "@/hooks/store/use-member"; import { useProjectInbox } from "@/hooks/store/use-project-inbox"; +import { InboxAppliedFilterChip } from "./chip"; type InboxIssueAppliedFiltersMember = { filterKey: TInboxIssueFilterMemberKeys; @@ -39,39 +39,29 @@ export const InboxIssueAppliedFiltersMember = observer(function InboxIssueApplie const clearFilter = () => handleInboxIssueFilters(filterKey, undefined); if (filteredValues.length === 0) return <>; - return ( - -
{label}
- {filteredValues.map((value) => { - const optionDetail = currentOptionDetail(value); - if (!optionDetail) return <>; - return ( -
-
- -
-
{optionDetail?.display_name}
-
handleInboxIssueFilters(filterKey, handleFilterValue(value))} - > - -
-
- ); - })} -
- -
-
+ const values = filteredValues + .map((value) => { + const optionDetail = currentOptionDetail(value); + if (!optionDetail) return undefined; + + return { + key: value, + label: optionDetail.display_name, + icon: ( + + ), + onRemove: () => handleInboxIssueFilters(filterKey, handleFilterValue(value)), + }; + }) + .filter((value): value is NonNullable => !!value); + + return ( + ); }); diff --git a/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/priority.tsx b/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/priority.tsx index 38a1260..2ac69d7 100644 --- a/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/priority.tsx +++ b/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/priority.tsx @@ -7,11 +7,11 @@ import { observer } from "mobx-react"; import { ISSUE_PRIORITIES } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { PriorityIcon, CloseIcon } from "@plane/propel/icons"; +import { PriorityIcon } from "@plane/propel/icons"; import type { TIssuePriorities } from "@plane/types"; -import { Tag } from "@plane/ui"; // hooks import { useProjectInbox } from "@/hooks/store/use-project-inbox"; +import { InboxAppliedFilterChip } from "./chip"; export const InboxIssueAppliedFiltersPriority = observer(function InboxIssueAppliedFiltersPriority() { // hooks @@ -28,34 +28,22 @@ export const InboxIssueAppliedFiltersPriority = observer(function InboxIssueAppl const clearFilter = () => handleInboxIssueFilters("priority", undefined); if (filteredValues.length === 0) return <>; - return ( - -
{t("common.priority")}
- {filteredValues.map((value) => { - const optionDetail = currentOptionDetail(value); - if (!optionDetail) return <>; - return ( -
-
- -
-
{optionDetail?.title}
-
handleInboxIssueFilters("priority", handleFilterValue(optionDetail?.key))} - > - -
-
- ); - })} -
- -
-
+ const values = filteredValues + .map((value) => { + const optionDetail = currentOptionDetail(value); + if (!optionDetail) return undefined; + + return { + key: value, + label: optionDetail.title, + icon: , + onRemove: () => handleInboxIssueFilters("priority", handleFilterValue(optionDetail.key)), + }; + }) + .filter((value): value is NonNullable => !!value); + + return ( + ); }); diff --git a/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/root.tsx b/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/root.tsx index 99283e6..2559d88 100644 --- a/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/root.tsx +++ b/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/root.tsx @@ -5,8 +5,8 @@ */ import { observer } from "mobx-react"; -// plane imports -import { Header, EHeaderVariant } from "@plane/ui"; +import { useTranslation } from "@plane/i18n"; +import { cn } from "@plane/utils"; // hooks import { useProjectInbox } from "@/hooks/store/use-project-inbox"; // local imports @@ -17,28 +17,30 @@ import { InboxIssueAppliedFiltersPriority } from "./priority"; import { InboxIssueAppliedFiltersState } from "./state"; import { InboxIssueAppliedFiltersStatus } from "./status"; -export const InboxIssueAppliedFilters = observer(function InboxIssueAppliedFilters() { +type TInboxIssueAppliedFiltersProps = { + className?: string; +}; + +export const InboxIssueAppliedFilters = observer(function InboxIssueAppliedFilters( + props: TInboxIssueAppliedFiltersProps +) { + const { className } = props; + const { t } = useTranslation(); const { getAppliedFiltersCount } = useProjectInbox(); if (getAppliedFiltersCount === 0) return <>; return ( -
- {/* status */} - - {/* state */} - - {/* priority */} - - {/* assignees */} - - {/* created_by */} - - {/* label */} - - {/* created_at */} - - {/* updated_at */} - -
+
+
+ + + + + + + + +
+
); }); diff --git a/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/state.tsx b/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/state.tsx index f8b94c9..80c6f92 100644 --- a/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/state.tsx +++ b/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/state.tsx @@ -6,13 +6,15 @@ import { observer } from "mobx-react"; import { EIconSize } from "@plane/constants"; -import { StateGroupIcon, CloseIcon } from "@plane/propel/icons"; -import { Tag } from "@plane/ui"; +import { useTranslation } from "@plane/i18n"; +import { StateGroupIcon } from "@plane/propel/icons"; // hooks import { useProjectInbox } from "@/hooks/store/use-project-inbox"; import { useProjectState } from "@/hooks/store/use-project-state"; +import { InboxAppliedFilterChip } from "./chip"; export const InboxIssueAppliedFiltersState = observer(function InboxIssueAppliedFiltersState() { + const { t } = useTranslation(); // hooks const { inboxFilters, handleInboxIssueFilters } = useProjectInbox(); const { getStateById } = useProjectState(); @@ -26,34 +28,22 @@ export const InboxIssueAppliedFiltersState = observer(function InboxIssueApplied const clearFilter = () => handleInboxIssueFilters("state", undefined); if (filteredValues.length === 0) return <>; - return ( - -
State
- {filteredValues.map((value) => { - const optionDetail = currentOptionDetail(value); - if (!optionDetail) return <>; - return ( -
-
- -
-
{optionDetail?.name}
-
handleInboxIssueFilters("state", handleFilterValue(optionDetail?.id))} - > - -
-
- ); - })} -
- -
-
+ const values = filteredValues + .map((value) => { + const optionDetail = currentOptionDetail(value); + if (!optionDetail) return undefined; + + return { + key: value, + label: optionDetail.name, + icon: , + onRemove: () => handleInboxIssueFilters("state", handleFilterValue(optionDetail.id)), + }; + }) + .filter((value): value is NonNullable => !!value); + + return ( + ); }); diff --git a/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/status.tsx b/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/status.tsx index ccc7c31..991e966 100644 --- a/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/status.tsx +++ b/plane-src/apps/web/core/components/inbox/inbox-filter/applied-filters/status.tsx @@ -7,13 +7,12 @@ import { observer } from "mobx-react"; import { INBOX_STATUS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { CloseIcon } from "@plane/propel/icons"; -import type { TInboxIssueStatus } from "@plane/types"; +import { EInboxIssueStatus, type TInboxIssueStatus } from "@plane/types"; // constants -import { Tag } from "@plane/ui"; // hooks import { useProjectInbox } from "@/hooks/store/use-project-inbox"; import { InboxStatusIcon } from "../../inbox-status-icon"; +import { InboxAppliedFilterChip } from "./chip"; export const InboxIssueAppliedFiltersStatus = observer(function InboxIssueAppliedFiltersStatus() { // hooks @@ -21,35 +20,37 @@ export const InboxIssueAppliedFiltersStatus = observer(function InboxIssueApplie const { t } = useTranslation(); // derived values const filteredValues = inboxFilters?.status || []; + const shouldHideDefaultOpenFilter = + filteredValues.length === 1 && filteredValues[0] === EInboxIssueStatus.PENDING; const currentOptionDetail = (status: TInboxIssueStatus) => INBOX_STATUS.find((s) => s.status === status) || undefined; const handleFilterValue = (value: TInboxIssueStatus): TInboxIssueStatus[] => filteredValues?.includes(value) ? filteredValues.filter((v) => v !== value) : [...filteredValues, value]; - if (filteredValues.length === 0) return <>; + if (filteredValues.length === 0 || shouldHideDefaultOpenFilter) return <>; + + const values = filteredValues + .map((value) => { + const optionDetail = currentOptionDetail(value); + if (!optionDetail) return undefined; + + return { + key: String(value), + label: t(optionDetail.i18n_title), + icon: , + onRemove: + handleFilterValue(optionDetail.status).length >= 1 + ? () => handleInboxIssueFilters("status", handleFilterValue(optionDetail.status)) + : undefined, + }; + }) + .filter((value): value is NonNullable => !!value); + return ( - -
Status
- {filteredValues.map((value) => { - const optionDetail = currentOptionDetail(value); - if (!optionDetail) return <>; - return ( -
-
- -
-
{t(optionDetail?.i18n_title)}
- {handleFilterValue(optionDetail?.status).length >= 1 && ( -
handleInboxIssueFilters("status", handleFilterValue(optionDetail?.status))} - > - -
- )} -
- ); - })} -
+ handleInboxIssueFilters("status", [EInboxIssueStatus.PENDING] as TInboxIssueStatus[])} + /> ); }); diff --git a/plane-src/apps/web/core/components/inbox/inbox-filter/filters/date.tsx b/plane-src/apps/web/core/components/inbox/inbox-filter/filters/date.tsx index 51fbbe6..13ee66e 100644 --- a/plane-src/apps/web/core/components/inbox/inbox-filter/filters/date.tsx +++ b/plane-src/apps/web/core/components/inbox/inbox-filter/filters/date.tsx @@ -8,6 +8,7 @@ import { useState } from "react"; import { concat, uniq } from "lodash-es"; import { observer } from "mobx-react"; import { PAST_DURATION_FILTER_OPTIONS } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; import type { TInboxIssueFilterDateKeys } from "@plane/types"; // components import { DateFilterModal } from "@/components/core/filters/date-filter-modal"; @@ -29,6 +30,7 @@ const isDate = (date: string) => { export const FilterDate = observer(function FilterDate(props: Props) { const { filterKey, label, searchQuery } = props; + const { t } = useTranslation(); // hooks const { inboxFilters, handleInboxIssueFilters } = useProjectInbox(); // state @@ -64,11 +66,11 @@ export const FilterDate = observer(function FilterDate(props: Props) { handleClose={() => setIsDateFilterModalOpen(false)} isOpen={isDateFilterModalOpen} onSelect={(val) => handleInboxIssueFilters(filterKey, val)} - title="Created date" + title={label || t("created_at")} /> )} 0 ? ` (${appliedFiltersCount})` : ""}`} + title={`${label || t("created_at")}${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`} isPreviewEnabled={previewEnabled} handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} /> @@ -93,7 +95,7 @@ export const FilterDate = observer(function FilterDate(props: Props) { /> ) : ( -

No matches found

+

{t("common.search.no_matches_found")}

)}
)} diff --git a/plane-src/apps/web/core/components/inbox/inbox-filter/filters/filter-selection.tsx b/plane-src/apps/web/core/components/inbox/inbox-filter/filters/filter-selection.tsx index 99ae144..ef63b6c 100644 --- a/plane-src/apps/web/core/components/inbox/inbox-filter/filters/filter-selection.tsx +++ b/plane-src/apps/web/core/components/inbox/inbox-filter/filters/filter-selection.tsx @@ -6,6 +6,7 @@ import { useState } from "react"; import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; import { SearchIcon, CloseIcon } from "@plane/propel/icons"; // hooks import { useLabel } from "@/hooks/store/use-label"; @@ -19,6 +20,7 @@ import { FilterPriority } from "./priority"; import { FilterStatus } from "./status"; export const InboxIssueFilterSelection = observer(function InboxIssueFilterSelection() { + const { t } = useTranslation(); // hooks const { isMobile } = usePlatformOS(); const { @@ -30,63 +32,63 @@ export const InboxIssueFilterSelection = observer(function InboxIssueFilterSelec return (
-
-
+
+
setFiltersSearchQuery(e.target.value)} autoFocus={!isMobile} /> {filtersSearchQuery !== "" && ( - )}
-
+
{/* status */} -
+
{/* Priority */} -
+
{/* assignees */} -
+
{/* Created By */} -
+
{/* Labels */} -
+
{/* Created at */} -
- +
+
{/* Updated at */} -
- +
+
diff --git a/plane-src/apps/web/core/components/inbox/inbox-filter/filters/labels.tsx b/plane-src/apps/web/core/components/inbox/inbox-filter/filters/labels.tsx index 23e338b..0852960 100644 --- a/plane-src/apps/web/core/components/inbox/inbox-filter/filters/labels.tsx +++ b/plane-src/apps/web/core/components/inbox/inbox-filter/filters/labels.tsx @@ -6,6 +6,7 @@ import { useState } from "react"; import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; import type { IIssueLabel } from "@plane/types"; import { Loader } from "@plane/ui"; // components @@ -24,6 +25,7 @@ type Props = { export const FilterLabels = observer(function FilterLabels(props: Props) { const { labels, searchQuery } = props; + const { t } = useTranslation(); const [itemsToRender, setItemsToRender] = useState(5); const [previewEnabled, setPreviewEnabled] = useState(true); @@ -49,7 +51,7 @@ export const FilterLabels = observer(function FilterLabels(props: Props) { return ( <> 0 ? ` (${appliedFiltersCount})` : ""}`} + title={`${t("labels")}${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`} isPreviewEnabled={previewEnabled} handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} /> @@ -78,7 +80,7 @@ export const FilterLabels = observer(function FilterLabels(props: Props) { )} ) : ( -

No matches found

+

{t("common.search.no_matches_found")}

) ) : ( diff --git a/plane-src/apps/web/core/components/inbox/inbox-filter/filters/members.tsx b/plane-src/apps/web/core/components/inbox/inbox-filter/filters/members.tsx index d555aa8..7f233c0 100644 --- a/plane-src/apps/web/core/components/inbox/inbox-filter/filters/members.tsx +++ b/plane-src/apps/web/core/components/inbox/inbox-filter/filters/members.tsx @@ -7,6 +7,7 @@ import { useMemo, useState } from "react"; import { sortBy } from "lodash-es"; import { observer } from "mobx-react"; +import { useTranslation } from "@plane/i18n"; // plane types import type { TInboxIssueFilterMemberKeys } from "@plane/types"; // plane ui @@ -29,6 +30,7 @@ type Props = { export const FilterMember = observer(function FilterMember(props: Props) { const { filterKey, label = "Members", memberIds, searchQuery } = props; + const { t } = useTranslation(); // hooks const { inboxFilters, handleInboxIssueFilters } = useProjectInbox(); const { getUserDetails } = useMember(); @@ -107,7 +109,7 @@ export const FilterMember = observer(function FilterMember(props: Props) { )} ) : ( -

No matches found

+

{t("common.search.no_matches_found")}

) ) : ( diff --git a/plane-src/apps/web/core/components/inbox/inbox-filter/filters/status.tsx b/plane-src/apps/web/core/components/inbox/inbox-filter/filters/status.tsx index e08a713..71a5556 100644 --- a/plane-src/apps/web/core/components/inbox/inbox-filter/filters/status.tsx +++ b/plane-src/apps/web/core/components/inbox/inbox-filter/filters/status.tsx @@ -24,7 +24,7 @@ type Props = { export const FilterStatus = observer(function FilterStatus(props: Props) { const { searchQuery } = props; // hooks - const { currentTab, inboxFilters, handleInboxIssueFilters } = useProjectInbox(); + const { inboxFilters, handleInboxIssueFilters } = useProjectInbox(); const { t } = useTranslation(); // states const [previewEnabled, setPreviewEnabled] = useState(true); @@ -33,9 +33,7 @@ export const FilterStatus = observer(function FilterStatus(props: Props) { const appliedFiltersCount = filterValue?.length ?? 0; const filteredOptions = INBOX_STATUS.filter( (s) => - ((currentTab === "open" && [-2, 0].includes(s.status)) || - (currentTab === "closed" && [-1, 1, 2].includes(s.status))) && - s.key.includes(searchQuery.toLowerCase()) + (s.key.includes(searchQuery.toLowerCase()) || t(s.i18n_title).toLowerCase().includes(searchQuery.toLowerCase())) ); const handleFilterValue = (value: TInboxIssueStatus): TInboxIssueStatus[] => @@ -49,7 +47,7 @@ export const FilterStatus = observer(function FilterStatus(props: Props) { return ( <> 0 ? ` (${appliedFiltersCount})` : ""}`} + title={`Status${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`} isPreviewEnabled={previewEnabled} handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} /> @@ -66,7 +64,7 @@ export const FilterStatus = observer(function FilterStatus(props: Props) { /> )) ) : ( -

No matches found

+

{t("common.search.no_matches_found")}

)}
)} diff --git a/plane-src/apps/web/core/components/inbox/inbox-filter/root.tsx b/plane-src/apps/web/core/components/inbox/inbox-filter/root.tsx index 2413ccc..2aa1cc9 100644 --- a/plane-src/apps/web/core/components/inbox/inbox-filter/root.tsx +++ b/plane-src/apps/web/core/components/inbox/inbox-filter/root.tsx @@ -5,8 +5,8 @@ */ import { ListFilter } from "lucide-react"; -import { getButtonStyling } from "@plane/propel/button"; // plane imports +import { useTranslation } from "@plane/i18n"; import { ChevronDownIcon } from "@plane/propel/icons"; import { cn } from "@plane/utils"; // components @@ -17,27 +17,44 @@ import useSize from "@/hooks/use-window-size"; import { InboxIssueFilterSelection } from "./filters/filter-selection"; import { InboxIssueOrderByDropdown } from "./sorting/order-by"; -const smallButton = ; +type TFiltersRootProps = { + compact?: boolean; + className?: string; +}; -const largeButton = ( -
- - Filters - -
-); -export function FiltersRoot() { +export function FiltersRoot(props: TFiltersRootProps) { + const { compact = false, className } = props; const windowSize = useSize(); + const { t } = useTranslation(); + const useCompactButtons = compact || windowSize[0] <= 1280; + + const smallButton = ( +
+ +
+ ); + + const largeButton = ( +
+ + {t("filters")} + +
+ ); return ( -
+
- 1280 ? largeButton : smallButton} title="" placement="bottom-end"> +
- +
); diff --git a/plane-src/apps/web/core/components/inbox/inbox-filter/sorting/order-by.tsx b/plane-src/apps/web/core/components/inbox/inbox-filter/sorting/order-by.tsx index ad2e720..adbba4a 100644 --- a/plane-src/apps/web/core/components/inbox/inbox-filter/sorting/order-by.tsx +++ b/plane-src/apps/web/core/components/inbox/inbox-filter/sorting/order-by.tsx @@ -8,7 +8,6 @@ import { observer } from "mobx-react"; import { ArrowDownWideNarrow, ArrowUpWideNarrow } from "lucide-react"; import { INBOX_ISSUE_ORDER_BY_OPTIONS, INBOX_ISSUE_SORT_BY_OPTIONS } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { getButtonStyling } from "@plane/propel/button"; import { CheckIcon, ChevronDownIcon } from "@plane/propel/icons"; import type { TInboxIssueSortingOrderByKeys, TInboxIssueSortingSortByKeys } from "@plane/types"; import { CustomMenu } from "@plane/ui"; @@ -19,25 +18,41 @@ import { cn } from "@plane/utils"; import { useProjectInbox } from "@/hooks/store/use-project-inbox"; import useSize from "@/hooks/use-window-size"; -export const InboxIssueOrderByDropdown = observer(function InboxIssueOrderByDropdown() { +type TInboxIssueOrderByDropdownProps = { + compact?: boolean; +}; + +export const InboxIssueOrderByDropdown = observer(function InboxIssueOrderByDropdown( + props: TInboxIssueOrderByDropdownProps +) { + const { compact = false } = props; // hooks const { t } = useTranslation(); const windowSize = useSize(); + const useCompactButtons = compact || windowSize[0] <= 1280; const { inboxSorting, handleInboxIssueSorting } = useProjectInbox(); const orderByDetails = INBOX_ISSUE_ORDER_BY_OPTIONS.find((option) => inboxSorting?.order_by?.includes(option.key)) || undefined; const smallButton = inboxSorting?.sort_by === "asc" ? ( - +
+ +
) : ( - +
+ +
); const largeButton = ( -
+
{inboxSorting?.sort_by === "asc" ? ( - + ) : ( - + )} {t(orderByDetails?.i18n_label || "inbox_issue.order_by.created_at")} @@ -45,7 +60,7 @@ export const InboxIssueOrderByDropdown = observer(function InboxIssueOrderByDrop ); return ( 1280 ? largeButton : smallButton} + customButton={useCompactButtons ? smallButton : largeButton} placement="bottom-end" maxHeight="lg" closeOnSelect diff --git a/plane-src/apps/web/core/components/inbox/inbox-issue-status.tsx b/plane-src/apps/web/core/components/inbox/inbox-issue-status.tsx index 972d83e..2bb3c95 100644 --- a/plane-src/apps/web/core/components/inbox/inbox-issue-status.tsx +++ b/plane-src/apps/web/core/components/inbox/inbox-issue-status.tsx @@ -19,10 +19,12 @@ type Props = { inboxIssue: IInboxIssueStore; iconSize?: number; showDescription?: boolean; + className?: string; + labelClassName?: string; }; export const InboxIssueStatus = observer(function InboxIssueStatus(props: Props) { - const { inboxIssue, iconSize = 16, showDescription = false } = props; + const { inboxIssue, iconSize = 16, showDescription = false, className, labelClassName } = props; //hooks const { t } = useTranslation(); // derived values @@ -39,14 +41,15 @@ export const InboxIssueStatus = observer(function InboxIssueStatus(props: Props) return (
-
+
-
+
{inboxIssue?.status === 0 && inboxIssue?.snoozed_till ? description : t(inboxIssueStatusDetail.i18n_title)}
diff --git a/plane-src/apps/web/core/components/inbox/modals/create-modal/create-root.tsx b/plane-src/apps/web/core/components/inbox/modals/create-modal/create-root.tsx index e31e8da..b0f81b5 100644 --- a/plane-src/apps/web/core/components/inbox/modals/create-modal/create-root.tsx +++ b/plane-src/apps/web/core/components/inbox/modals/create-modal/create-root.tsx @@ -168,7 +168,7 @@ export const InboxIssueCreateRoot = observer(function InboxIssueCreateRoot(props setUploadedAssetIds([]); } if (!createMore) { - router.push(`/${workspaceSlug}/projects/${projectId}/intake/?currentTab=open&inboxIssueId=${res?.issue?.id}`); + router.push(`/${workspaceSlug}/projects/${projectId}/intake/?inboxIssueId=${res?.issue?.id}`); handleModalClose(); } else { descriptionEditorRef?.current?.clearEditor(); @@ -197,10 +197,10 @@ export const InboxIssueCreateRoot = observer(function InboxIssueCreateRoot(props if (!workspaceSlug || !projectId || !workspaceId) return <>; return ( -
-
-
-
+
+
+ +

{t("inbox_issue.modal.title")}

{duplicateIssues?.length > 0 && ( @@ -217,6 +217,7 @@ export const InboxIssueCreateRoot = observer(function InboxIssueCreateRoot(props data={formData} handleData={handleFormData} isTitleLengthMoreThan255Character={isTitleLengthMoreThan255Character} + inputClassName="nodedc-modal-input !px-4 !py-3 !text-[15px]" /> submitBtnRef?.current?.click()} onAssetUpload={(assetId) => setUploadedAssetIds((prev) => [...prev, assetId])} /> - +
-
+
setCreateMore((prevData) => !prevData)} role="button" tabIndex={getIndex("create_more")} > {}} size="sm" /> - {t("create_more")} + {t("create_more")}
@@ -280,7 +289,7 @@ export const InboxIssueCreateRoot = observer(function InboxIssueCreateRoot(props {shouldRenderDuplicateModal && (
; handleData: (issueKey: keyof Partial, issueValue: Partial[keyof Partial]) => void; isVisible?: boolean; + rootClassName?: string; + buttonClassName?: string; }; export const InboxIssueProperties = observer(function InboxIssueProperties(props: TInboxIssueProperties) { - const { projectId, data, handleData, isVisible = false } = props; + const { projectId, data, handleData, isVisible = false, rootClassName, buttonClassName } = props; // hooks const { areEstimateEnabledByProjectId } = useProjectEstimates(); const { isMobile } = usePlatformOS(); @@ -54,7 +56,7 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props maxDate?.setDate(maxDate.getDate()); return ( -
+
{/* intake state */}
handleData("state_id", stateId)} projectId={projectId} buttonVariant="border-with-text" + buttonClassName={buttonClassName} tabIndex={getIndex("state_id")} isForWorkItemCreation={!data?.id} /> @@ -73,6 +76,7 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props value={data?.priority} onChange={(priority) => handleData("priority", priority)} buttonVariant="border-with-text" + buttonClassName={buttonClassName} tabIndex={getIndex("priority")} />
@@ -84,7 +88,7 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props value={data?.assignee_ids || []} onChange={(assigneeIds) => handleData("assignee_ids", assigneeIds)} buttonVariant={(data?.assignee_ids || [])?.length > 0 ? "transparent-without-text" : "border-with-text"} - buttonClassName={(data?.assignee_ids || [])?.length > 0 ? "hover:bg-transparent" : ""} + buttonClassName={cn((data?.assignee_ids || [])?.length > 0 ? "hover:bg-transparent" : "", buttonClassName)} placeholder="Assignees" multiple tabIndex={getIndex("assignee_ids")} @@ -97,6 +101,7 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props value={data?.label_ids || []} onChange={(labelIds) => handleData("label_ids", labelIds)} projectId={projectId} + buttonClassName={buttonClassName} tabIndex={getIndex("label_ids")} />
@@ -108,6 +113,7 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props value={data?.start_date || null} onChange={(date) => handleData("start_date", date ? renderFormattedPayloadDate(date) : "")} buttonVariant="border-with-text" + buttonClassName={buttonClassName} minDate={minDate ?? undefined} placeholder="Start date" tabIndex={getIndex("start_date")} @@ -121,6 +127,7 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props value={data?.target_date || null} onChange={(date) => handleData("target_date", date ? renderFormattedPayloadDate(date) : "")} buttonVariant="border-with-text" + buttonClassName={buttonClassName} minDate={minDate ?? undefined} placeholder="Due date" tabIndex={getIndex("target_date")} @@ -136,6 +143,7 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props projectId={projectId} placeholder="Cycle" buttonVariant="border-with-text" + buttonClassName={buttonClassName} tabIndex={getIndex("cycle_id")} />
@@ -150,6 +158,7 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props projectId={projectId} placeholder="Modules" buttonVariant="border-with-text" + buttonClassName={buttonClassName} multiple showCount tabIndex={getIndex("module_ids")} @@ -165,6 +174,7 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props onChange={(estimatePoint) => handleData("estimate_point", estimatePoint)} projectId={projectId} buttonVariant="border-with-text" + buttonClassName={buttonClassName} placeholder="Estimate" tabIndex={getIndex("estimate_point")} /> @@ -179,7 +189,10 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props customButton={
)} -
-
- -
+
+ +
+
+ +
- {inboxIssueId ? ( - - ) : ( - - )} + {inboxIssueId ? ( +
+
+ +
+
+ ) : ( +
+
+
+ +
+
+
+ )} +
); diff --git a/plane-src/apps/web/core/components/inbox/sidebar/inbox-list-item.tsx b/plane-src/apps/web/core/components/inbox/sidebar/inbox-list-item.tsx index 90a3fbf..4070db4 100644 --- a/plane-src/apps/web/core/components/inbox/sidebar/inbox-list-item.tsx +++ b/plane-src/apps/web/core/components/inbox/sidebar/inbox-list-item.tsx @@ -9,19 +9,20 @@ import { observer } from "mobx-react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; // plane imports +import { useTranslation } from "@plane/i18n"; import { PriorityIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; -import { Row, Avatar } from "@plane/ui"; +import { Avatar } from "@plane/ui"; import { cn, renderFormattedDate, getFileURL } from "@plane/utils"; // components import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; +import { NodedcWorkItemCard, getNodedcWorkItemCardAppearance } from "@/components/issues/issue-layouts/shared/nodedc-work-item-card"; // hooks import { useLabel } from "@/hooks/store/use-label"; import { useMember } from "@/hooks/store/use-member"; import { useProjectInbox } from "@/hooks/store/use-project-inbox"; import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web imports -import { InboxSourcePill } from "@/plane-web/components/inbox/source-pill"; // local imports import { InboxIssueStatus } from "../inbox-issue-status"; @@ -38,8 +39,9 @@ export const InboxIssueListItem = observer(function InboxIssueListItem(props: In // router const searchParams = useSearchParams(); const selectedInboxIssueId = searchParams.get("inboxIssueId"); + const { t } = useTranslation(); // store - const { currentTab, getIssueInboxByIssueId } = useProjectInbox(); + const { getIssueInboxByIssueId } = useProjectInbox(); const { projectLabels } = useLabel(); const { isMobile } = usePlatformOS(); const { getUserDetails } = useMember(); @@ -54,89 +56,94 @@ export const InboxIssueListItem = observer(function InboxIssueListItem(props: In if (!issue) return <>; const createdByDetails = issue?.created_by ? getUserDetails(issue?.created_by) : undefined; + const isActive = selectedInboxIssueId === issue.id; + const { pillBackgroundClasses, subtleTextClasses } = getNodedcWorkItemCardAppearance(isActive); + const visibleLabels = (issue.label_ids ?? []) + .map((labelId) => projectLabels?.find((label) => label.id === labelId)) + .filter((label) => !!label) + .slice(0, 2); + const extraLabelCount = Math.max((issue.label_ids ?? []).length - visibleLabels.length, 0); return ( - <> - handleIssueRedirection(e, issue.id)} - > - -
-
-
- {projectIdentifier}-{issue.sequence_id} -
-
- {inboxIssue.source && } - {inboxIssue.status !== -2 && } + handleIssueRedirection(e, issue.id)} + > + +
+ {createdByDetails && createdByDetails.email?.includes("intake@plane.so") ? ( + + ) : createdByDetails ? ( + + ) : null} +
+
+ {createdByDetails?.display_name ?? "NODE.DC"} +
-

{issue.name}

-
-
-
+ {inboxIssue.status !== -2 && } +
+ } + subtitle={ + + {projectIdentifier}-{issue.sequence_id} + + } + title={issue.name} + titleClassName="line-clamp-3 text-left text-[1.05rem]" + titleContainerClassName="items-start justify-start px-0 py-5 text-left" + footerClassName="items-end" + footer={ + <> +
-
{renderFormattedDate(issue.created_at ?? "")}
+
+ {renderFormattedDate(issue.created_at ?? "")} +
-
+ {visibleLabels.map((labelDetails) => { + if (!labelDetails) return null; + return ( +
+ + {labelDetails.name} +
+ ); + })} - {issue.priority && ( - - - - )} - - {issue.label_ids && issue.label_ids.length > 3 ? ( -
- - {`${issue.label_ids.length} labels`} + {extraLabelCount > 0 && ( +
+ +{extraLabelCount}
- ) : ( - <> - {(issue.label_ids ?? []).map((labelId) => { - const labelDetails = projectLabels?.find((l) => l.id === labelId); - if (!labelDetails) return null; - return ( -
- - {labelDetails.name} -
- ); - })} - )}
- {/* created by */} - {createdByDetails && createdByDetails.email?.includes("intake@plane.so") ? ( - - ) : createdByDetails ? ( - - ) : null} -
- - - + +
+ {issue.priority && issue.priority !== "none" && ( + +
+ +
+
+ )} +
+ + } + /> + ); }); diff --git a/plane-src/apps/web/core/components/inbox/sidebar/root.tsx b/plane-src/apps/web/core/components/inbox/sidebar/root.tsx index b6a7f2b..25284fb 100644 --- a/plane-src/apps/web/core/components/inbox/sidebar/root.tsx +++ b/plane-src/apps/web/core/components/inbox/sidebar/root.tsx @@ -7,12 +7,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { observer } from "mobx-react"; import { useTranslation } from "@plane/i18n"; -import { EmptyStateDetailed } from "@plane/propel/empty-state"; -import type { TInboxIssueCurrentTab } from "@plane/types"; -import { EInboxIssueCurrentTab } from "@plane/types"; // plane imports -import { Header, Loader, EHeaderVariant } from "@plane/ui"; -import { cn } from "@plane/utils"; +import { Loader } from "@plane/ui"; // components import { InboxSidebarLoader } from "@/components/ui/loader/layouts/project-inbox/inbox-sidebar-loader"; // hooks @@ -21,8 +17,7 @@ import { useProjectInbox } from "@/hooks/store/use-project-inbox"; import { useAppRouter } from "@/hooks/use-app-router"; import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; // local imports -import { FiltersRoot } from "../inbox-filter"; -import { InboxIssueAppliedFilters } from "../inbox-filter/applied-filters/root"; +import { InboxEmptyState } from "../empty-state"; import { InboxIssueList } from "./inbox-list"; type IInboxSidebarProps = { @@ -32,17 +27,6 @@ type IInboxSidebarProps = { setIsMobileSidebar: (value: boolean) => void; }; -const tabNavigationOptions: { key: TInboxIssueCurrentTab; i18n_label: string }[] = [ - { - key: EInboxIssueCurrentTab.OPEN, - i18n_label: "inbox_issue.tabs.open", - }, - { - key: EInboxIssueCurrentTab.CLOSED, - i18n_label: "inbox_issue.tabs.closed", - }, -]; - export const InboxSidebar = observer(function InboxSidebar(props: IInboxSidebarProps) { const { workspaceSlug, projectId, inboxIssueId, setIsMobileSidebar } = props; // router @@ -55,8 +39,6 @@ export const InboxSidebar = observer(function InboxSidebar(props: IInboxSidebarP // store const { currentProjectDetails } = useProject(); const { - currentTab, - handleCurrentTab, loader, filteredInboxIssueIds, inboxIssuePaginationInfo, @@ -73,100 +55,41 @@ export const InboxSidebar = observer(function InboxSidebar(props: IInboxSidebarP useIntersectionObserver(containerRef, elementRef, fetchNextPages, "20%"); useEffect(() => { - if (workspaceSlug && projectId && currentTab && filteredInboxIssueIds.length > 0) { - if (inboxIssueId === undefined) { - router.push( - `/${workspaceSlug}/projects/${projectId}/intake?currentTab=${currentTab}&inboxIssueId=${filteredInboxIssueIds[0]}` - ); - } - } - }, [currentTab, filteredInboxIssueIds, inboxIssueId, projectId, router, workspaceSlug]); + if (!workspaceSlug || !projectId || inboxIssueId !== undefined || filteredInboxIssueIds.length === 0) return; + + router.push(`/${workspaceSlug}/projects/${projectId}/intake?inboxIssueId=${filteredInboxIssueIds[0]}`); + }, [filteredInboxIssueIds, inboxIssueId, projectId, router, workspaceSlug]); return ( -
+
-
- {tabNavigationOptions.map((option) => ( -
{ - if (currentTab != option?.key) { - handleCurrentTab(workspaceSlug, projectId, option?.key); - router.push(`/${workspaceSlug}/projects/${projectId}/intake?currentTab=${option?.key}`); - } - }} - > -
{t(option?.i18n_label)}
- {option?.key === "open" && currentTab === option?.key && ( -
- {inboxIssuePaginationInfo?.total_results || 0} -
- )} -
-
- ))} -
- -
-
- - {loader != undefined && loader === "filter-loading" && !inboxIssuePaginationInfo?.next_page_results ? ( ) : ( -
+
{filteredInboxIssueIds.length > 0 ? ( - +
+ +
) : (
{getAppliedFiltersCount > 0 ? ( - - ) : currentTab === EInboxIssueCurrentTab.OPEN ? ( - router.push(`/${workspaceSlug}/projects/${projectId}/intake`), - variant: "primary", - }, - ]} - rootClassName="px-page-x" /> ) : ( - )}
diff --git a/plane-src/apps/web/core/components/issues/issue-layouts/calendar/issue-block.tsx b/plane-src/apps/web/core/components/issues/issue-layouts/calendar/issue-block.tsx index 5902c4c..373d175 100644 --- a/plane-src/apps/web/core/components/issues/issue-layouts/calendar/issue-block.tsx +++ b/plane-src/apps/web/core/components/issues/issue-layouts/calendar/issue-block.tsx @@ -4,12 +4,11 @@ * See the LICENSE file for details. */ -import { useState, useRef, forwardRef } from "react"; +import { useRef, forwardRef } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { MoreHorizontal } from "lucide-react"; // plane imports -import { useOutsideClickDetector } from "@plane/hooks"; import { Popover } from "@plane/propel/popover"; import type { TIssue } from "@plane/types"; import { ControlLink } from "@plane/ui"; @@ -39,11 +38,8 @@ type Props = { export const CalendarIssueBlock = observer( forwardRef(function CalendarIssueBlock(props: Props, ref: React.ForwardedRef) { const { issue, quickActions, isDragging = false, isEpic = false } = props; - // states - const [isMenuActive, setIsMenuActive] = useState(false); // refs const blockRef = useRef(null); - const menuActionRef = useRef(null); // hooks const { workspaceSlug } = useParams(); const { getProjectStates } = useProjectState(); @@ -60,25 +56,14 @@ export const CalendarIssueBlock = observer( // handlers const handleIssuePeekOverview = (issue: TIssue) => handleRedirection(workspaceSlug.toString(), issue, isMobile); - useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false)); - const customActionButton = (
setIsMenuActive(!isMenuActive)} + className="w-full cursor-pointer rounded-sm p-1 text-secondary hover:bg-layer-1 hover:text-primary" >
); - const isMenuActionRefAboveScreenBottom = - menuActionRef?.current && menuActionRef?.current?.getBoundingClientRect().bottom < window.innerHeight - 220; - - const placement = isMenuActionRefAboveScreenBottom ? "bottom-end" : "top-end"; - const workItemLink = generateWorkItemLink({ workspaceSlug: workspaceSlug?.toString(), projectId: issue?.project_id, @@ -138,8 +123,7 @@ export const CalendarIssueBlock = observer(
{ e.preventDefault(); @@ -150,7 +134,6 @@ export const CalendarIssueBlock = observer( issue, parentRef: blockRef, customActionButton, - placement, })}
diff --git a/plane-src/apps/web/core/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx b/plane-src/apps/web/core/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx index 80f7493..c790d05 100644 --- a/plane-src/apps/web/core/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx +++ b/plane-src/apps/web/core/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx @@ -8,7 +8,7 @@ import React, { Fragment, useState } from "react"; import type { Placement } from "@popperjs/core"; import { usePopper } from "react-popper"; // headless ui -import { Popover, Transition } from "@headlessui/react"; +import { Popover, Portal, Transition } from "@headlessui/react"; // ui import { Button } from "@plane/propel/button"; @@ -99,19 +99,21 @@ export function FiltersDropdown(props: Props) { leaveFrom="opacity-100 translate-y-0" leaveTo="opacity-0 translate-y-1" > - {/** translate-y-0 is a hack to create new stacking context. Required for safari */} - -
-
- {children} + + {/** translate-y-0 is a hack to create new stacking context. Required for safari */} + +
+
+ {children} +
-
- + + )} diff --git a/plane-src/apps/web/core/components/issues/issue-layouts/filters/header/helpers/filter-header.tsx b/plane-src/apps/web/core/components/issues/issue-layouts/filters/header/helpers/filter-header.tsx index b09e0e5..d9ca207 100644 --- a/plane-src/apps/web/core/components/issues/issue-layouts/filters/header/helpers/filter-header.tsx +++ b/plane-src/apps/web/core/components/issues/issue-layouts/filters/header/helpers/filter-header.tsx @@ -15,7 +15,7 @@ type Props = { export function FilterHeader({ title, isPreviewEnabled, handleIsPreviewEnabled }: Props) { return ( -
+
{title}
- } - disabled={item.disabled} - className={cn( - "flex items-center gap-2", - { - "text-placeholder": item.disabled, - }, - item.className - )} - > - {item.nestedMenuItems.map((nestedItem) => ( - { - nestedItem.action(); - }} - className={cn( - "flex items-center gap-2", - { - "text-placeholder": nestedItem.disabled, - }, - nestedItem.className - )} - disabled={nestedItem.disabled} - > - {nestedItem.icon && } -
-
{nestedItem.title}
- {nestedItem.description && ( -

- {nestedItem.description} -

- )} -
-
- ))} - - ); - } - - // Render regular menu item - return ( - { - item.action(); - }} - className={cn( - "flex items-center gap-2", - { - "text-placeholder": item.disabled, - }, - item.className - )} - disabled={item.disabled} - > - {item.icon && } -
-
{item.title}
- {item.description && ( -

- {item.description} -

- )} -
-
- ); - })} - + ); }); diff --git a/plane-src/apps/web/core/components/issues/issue-layouts/quick-add/form/kanban.tsx b/plane-src/apps/web/core/components/issues/issue-layouts/quick-add/form/kanban.tsx index f6a1aba..59dc663 100644 --- a/plane-src/apps/web/core/components/issues/issue-layouts/quick-add/form/kanban.tsx +++ b/plane-src/apps/web/core/components/issues/issue-layouts/quick-add/form/kanban.tsx @@ -12,7 +12,7 @@ export const KanbanQuickAddIssueForm = observer(function KanbanQuickAddIssueForm const { ref, projectDetail, register, onSubmit, isEpic } = props; const { t } = useTranslation(); return ( -
+

{projectDetail?.identifier ?? "..."}

diff --git a/plane-src/apps/web/core/components/issues/issue-layouts/shared/nodedc-work-item-card.tsx b/plane-src/apps/web/core/components/issues/issue-layouts/shared/nodedc-work-item-card.tsx new file mode 100644 index 0000000..dd51c46 --- /dev/null +++ b/plane-src/apps/web/core/components/issues/issue-layouts/shared/nodedc-work-item-card.tsx @@ -0,0 +1,75 @@ +/** + * 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 { cn } from "@plane/utils"; + +type TNodedcWorkItemCardProps = { + isActive: boolean; + header: ReactNode; + title: ReactNode; + footer: ReactNode; + subtitle?: ReactNode; + surfaceClassName?: string; + contentClassName?: string; + titleClassName?: string; + titleContainerClassName?: string; + subtitleClassName?: string; + footerClassName?: string; +}; + +export const getNodedcWorkItemCardAppearance = (isActive: boolean) => ({ + surfaceClassName: isActive + ? "bg-[rgb(var(--nodedc-card-active-rgb))] text-[#111111]" + : "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white", + foregroundClasses: isActive ? "text-[#111111]" : "text-white", + subtleTextClasses: isActive ? "text-[#2F4721]" : "text-[#B3B3B8]", + pillBackgroundClasses: isActive ? "bg-black/10 text-[#111111]" : "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white", + iconBubbleClasses: isActive ? "bg-black text-[rgb(var(--nodedc-card-active-rgb))]" : "bg-[#111214] text-white", +}); + +export const NodedcWorkItemCard = ({ + isActive, + header, + title, + footer, + subtitle, + surfaceClassName, + contentClassName, + titleClassName, + titleContainerClassName, + subtitleClassName, + footerClassName, +}: TNodedcWorkItemCardProps) => { + const appearance = getNodedcWorkItemCardAppearance(isActive); + + return ( +
+
+
+ {header} + {subtitle && ( +
+ {subtitle} +
+ )} +
+ +
+
{title}
+
+ +
{footer}
+
+
+ ); +}; diff --git a/plane-src/apps/web/core/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/plane-src/apps/web/core/components/issues/issue-layouts/spreadsheet/issue-row.tsx index 6d060c4..3c374f8 100644 --- a/plane-src/apps/web/core/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/plane-src/apps/web/core/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -10,8 +10,6 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { MoreHorizontal } from "lucide-react"; import { SPREADSHEET_SELECT_GROUP } from "@plane/constants"; -// plane helpers -import { useOutsideClickDetector } from "@plane/hooks"; import { ChevronRightIcon } from "@plane/propel/icons"; // types import { Tooltip } from "@plane/propel/tooltip"; @@ -189,11 +187,8 @@ const IssueRowDetails = observer(function IssueRowDetails(props: IssueRowDetails selectionHelpers, isEpic = false, } = props; - // states - const [isMenuActive, setIsMenuActive] = useState(false); // refs const cellRef = useRef(null); - const menuActionRef = useRef(null); // router const { workspaceSlug, projectId } = useParams(); // hooks @@ -212,15 +207,9 @@ const IssueRowDetails = observer(function IssueRowDetails(props: IssueRowDetails const subIssueIndentation = `${spacingLeft}px`; - useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false)); - const customActionButton = (
setIsMenuActive(!isMenuActive)} + className="flex h-full w-full cursor-pointer items-center rounded-sm p-1 text-secondary hover:bg-layer-1 hover:text-primary" >
@@ -371,7 +360,7 @@ const IssueRowDetails = observer(function IssueRowDetails(props: IssueRowDetails
e.stopPropagation()} > {quickActions({ diff --git a/plane-src/apps/web/core/components/issues/peek-overview/header.tsx b/plane-src/apps/web/core/components/issues/peek-overview/header.tsx index 50b0222..a4c6fd6 100644 --- a/plane-src/apps/web/core/components/issues/peek-overview/header.tsx +++ b/plane-src/apps/web/core/components/issues/peek-overview/header.tsx @@ -4,7 +4,7 @@ * See the LICENSE file for details. */ -import { useRef } from "react"; +import { useRef, type ReactNode } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { MoveDiagonal, MoveRight } from "lucide-react"; @@ -65,6 +65,12 @@ export type PeekOverviewHeaderProps = { toggleEditIssueModal: (value: boolean) => void; handleRestoreIssue: () => Promise; isSubmitting: TNameDescriptionLoader; + actionSlot?: ReactNode; + metaSlot?: ReactNode; + showCopyLink?: boolean; + showLayoutSwitcher?: boolean; + showQuickActions?: boolean; + showSubscription?: boolean; }; export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader(props: PeekOverviewHeaderProps) { @@ -84,6 +90,12 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader toggleEditIssueModal, handleRestoreIssue, isSubmitting, + actionSlot, + metaSlot, + showCopyLink = true, + showLayoutSwitcher = !embedIssue, + showQuickActions = true, + showSubscription = true, } = props; // ref const parentRef = useRef(null); @@ -154,11 +166,12 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader return (
-
+
-
+
-
- {currentUser && !isArchived && ( +
+ {actionSlot} + {showSubscription && currentUser && !isArchived && ( )} - - - - {issueDetails && ( + {showCopyLink && ( + + + + )} + {showQuickActions && issueDetails && ( void; + interactiveEmbeddedLayout?: boolean; issueOperations: TIssueOperations; + renderHeader?: (props: IIssueViewHeaderRenderProps) => ReactNode; + renderContent?: (props: IIssueViewContentRenderProps) => ReactNode; +} + +export interface IIssueViewHeaderRenderProps { + peekMode: TPeekModes; + setPeekMode: (value: TPeekModes) => void; + removeRoutePeekId: () => void; + isSubmitting: TNameDescriptionLoader; +} + +export interface IIssueViewContentRenderProps { + peekMode: TPeekModes; + isSubmitting: TNameDescriptionLoader; + setIsSubmitting: (value: TNameDescriptionLoader) => void; + editorRef: React.RefObject; } export const IssueView = observer(function IssueView(props: IIssueView) { @@ -53,7 +77,10 @@ export const IssueView = observer(function IssueView(props: IIssueView) { disabled = false, embedIssue = false, embedRemoveCurrentNotification, + interactiveEmbeddedLayout = false, issueOperations, + renderHeader, + renderContent, } = props; // states const [peekMode, setPeekMode] = useState("side-peek"); @@ -85,6 +112,10 @@ export const IssueView = observer(function IssueView(props: IIssueView) { } = useIssueDetail(); const { isAnyModalOpen: isAnyEpicModalOpen } = useIssueDetail(EIssueServiceType.EPICS); const issue = getIssueById(issueId); + const shouldUseInteractiveEmbeddedLayout = embedIssue && interactiveEmbeddedLayout; + const shouldRenderPeekSurface = !embedIssue || shouldUseInteractiveEmbeddedLayout; + const shouldAllowPeekModeToggle = !embedIssue || shouldUseInteractiveEmbeddedLayout; + const shouldAllowPeekResize = !embedIssue || shouldUseInteractiveEmbeddedLayout; // remove peek id const removeRoutePeekId = () => { setPeekIssue(undefined); @@ -118,13 +149,13 @@ export const IssueView = observer(function IssueView(props: IIssueView) { const startPeekResizing = useCallback( (event: ReactMouseEvent) => { - if (peekMode !== "side-peek") return; + if (!shouldAllowPeekResize || peekMode !== "side-peek") return; event.preventDefault(); setIsResizingPeek(true); initialPeekWidthRef.current = sidePeekWidth; initialMouseXRef.current = event.clientX; }, - [peekMode, sidePeekWidth] + [peekMode, shouldAllowPeekResize, sidePeekWidth] ); useEffect(() => { @@ -204,14 +235,21 @@ export const IssueView = observer(function IssueView(props: IIssueView) { }; const peekOverviewIssueClassName = cn( - !embedIssue - ? "absolute z-[25] flex flex-col overflow-hidden border border-subtle/70 bg-surface-1/80 backdrop-blur-2xl transition-all duration-300" - : `h-full w-full`, + shouldRenderPeekSurface + ? "flex flex-col overflow-hidden border border-subtle/70 bg-surface-1/80 backdrop-blur-2xl transition-all duration-300" + : "h-full w-full", + !embedIssue && "absolute z-[25]", !embedIssue && { "top-3 right-3 bottom-3 w-[calc(100%-1.5rem)] rounded-[28px] border md:min-w-[640px] md:max-w-[calc(100vw-1.5rem)]": peekMode === "side-peek", "top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal", "absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen", + }, + shouldUseInteractiveEmbeddedLayout && { + "relative ml-auto h-full rounded-[28px]": peekMode === "side-peek", + "relative mx-auto my-3 h-[calc(100%-1.5rem)] w-full max-w-[min(1080px,100%)] rounded-[28px]": + peekMode === "modal", + "relative h-full w-full rounded-[28px]": peekMode === "full-screen", } ); @@ -226,12 +264,14 @@ export const IssueView = observer(function IssueView(props: IIssueView) { ref={issuePeekOverviewRef} className={peekOverviewIssueClassName} style={{ - width: !embedIssue && peekMode === "side-peek" ? `${sidePeekWidth}px` : undefined, + width: shouldAllowPeekResize && peekMode === "side-peek" ? `${sidePeekWidth}px` : undefined, boxShadow: - "0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), 0px 1px 16px 0px rgba(16, 24, 40, 0.12)", + shouldRenderPeekSurface + ? "0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), 0px 1px 16px 0px rgba(16, 24, 40, 0.12)" + : undefined, }} > - {!embedIssue && peekMode === "side-peek" && ( + {shouldAllowPeekResize && peekMode === "side-peek" && (
- {/* header */} - setPeekMode(value)} - removeRoutePeekId={removeRoutePeekId} - toggleDeleteIssueModal={toggleDeleteIssueModal} - toggleArchiveIssueModal={toggleArchiveIssueModal} - toggleDuplicateIssueModal={toggleDuplicateIssueModal} - toggleEditIssueModal={toggleEditIssueModal} - handleRestoreIssue={handleRestore} - isArchived={is_archived} - issueId={issueId} - workspaceSlug={workspaceSlug} - projectId={projectId} - isSubmitting={isSubmitting} - disabled={disabled} - embedIssue={embedIssue} - /> + {renderHeader ? ( + renderHeader({ + peekMode, + setPeekMode: (value) => setPeekMode(value), + removeRoutePeekId, + isSubmitting, + }) + ) : ( + setPeekMode(value)} + removeRoutePeekId={removeRoutePeekId} + toggleDeleteIssueModal={toggleDeleteIssueModal} + toggleArchiveIssueModal={toggleArchiveIssueModal} + toggleDuplicateIssueModal={toggleDuplicateIssueModal} + toggleEditIssueModal={toggleEditIssueModal} + handleRestoreIssue={handleRestore} + isArchived={is_archived} + issueId={issueId} + workspaceSlug={workspaceSlug} + projectId={projectId} + isSubmitting={isSubmitting} + disabled={disabled} + embedIssue={embedIssue} + showLayoutSwitcher={shouldAllowPeekModeToggle} + /> + )} {/* content */}
- {["side-peek", "modal"].includes(peekMode) ? ( + {renderContent ? ( + renderContent({ + peekMode, + isSubmitting, + setIsSubmitting: (value) => setIsSubmitting(value), + editorRef, + }) + ) : ["side-peek", "modal"].includes(peekMode) ? (
; @@ -20,50 +17,10 @@ export interface Props { export const WorkspaceDraftIssueQuickActions = observer(function WorkspaceDraftIssueQuickActions(props: Props) { const { parentRef, MENU_ITEMS } = props; - const { t } = useTranslation(); - return ( <> - - {MENU_ITEMS.map((item) => ( - { - item.action(); - }} - className={cn( - "flex items-center gap-2", - { - "text-placeholder": item.disabled, - }, - item.className - )} - disabled={item.disabled} - > - {item.icon && } -
-
{t(item.title || "")}
- {item.description && ( -

- {item.description} -

- )} -
-
- ))} -
+ ); }); diff --git a/plane-src/apps/web/core/components/workspace/sidebar/projects-list-item.tsx b/plane-src/apps/web/core/components/workspace/sidebar/projects-list-item.tsx index 7bbf69f..c19285d 100644 --- a/plane-src/apps/web/core/components/workspace/sidebar/projects-list-item.tsx +++ b/plane-src/apps/web/core/components/workspace/sidebar/projects-list-item.tsx @@ -57,6 +57,15 @@ type Props = { disableDrop?: boolean; isLastChild: boolean; renderInExtendedSidebar?: boolean; + renderInToolbarMenu?: boolean; +}; + +type TProjectActionItem = { + key: string; + label: string; + icon: React.ReactNode; + onClick: () => void; + analytics?: string; }; export const SidebarProjectsListItem = observer(function SidebarProjectsListItem(props: Props) { @@ -69,6 +78,7 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem handleOnProjectDrop, projectListType, renderInExtendedSidebar = false, + renderInToolbarMenu = false, } = props; // store hooks const { t } = useTranslation(); @@ -87,7 +97,6 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem const isProjectListOpen = getIsProjectListOpen(projectId); const [instruction, setInstruction] = useState<"DRAG_OVER" | "DRAG_BELOW" | undefined>(undefined); // refs - const actionSectionRef = useRef(null); const projectRef = useRef(null); const dragHandleRef = useRef(null); // router @@ -135,6 +144,50 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem setLeaveProjectModal(true); }; + const projectActionItems: TProjectActionItem[] = [ + isAdmin + ? { + key: "publish", + label: t("publish_project"), + icon: , + onClick: () => setPublishModal(true), + } + : null, + { + key: "copy-link", + label: t("copy_link"), + icon: , + onClick: handleCopyText, + }, + isAuthorized + ? { + key: "archives", + label: t("archives"), + icon: , + onClick: () => { + router.push(`/${workspaceSlug}/projects/${project?.id}/archives/issues`); + }, + } + : null, + { + key: "settings", + label: t("settings"), + icon: , + onClick: () => { + router.push(`/${workspaceSlug}/settings/projects/${project?.id}`); + }, + }, + !isAuthorized + ? { + key: "leave-project", + label: t("leave_project"), + icon: , + onClick: handleLeaveProject, + analytics: MEMBER_TRACKER_ELEMENTS.SIDEBAR_PROJECT_QUICK_ACTIONS, + } + : null, + ].filter((item): item is TProjectActionItem => Boolean(item)); + useEffect(() => { const element = projectRef.current; const dragHandleElement = dragHandleRef.current; @@ -229,7 +282,6 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem else toggleAnySidebarDropdown(false); }, [isMenuActive, toggleAnySidebarDropdown]); - useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false)); useOutsideClickDetector(projectRef, () => projectRef?.current?.classList?.remove(HIGHLIGHT_CLASS)); useEffect(() => { @@ -352,98 +404,48 @@ export const SidebarProjectsListItem = observer(function SidebarProjectsListItem )}
- setIsMenuActive(!isMenuActive)} - className="text-placeholder" - /> - } - className={cn( - "pointer-events-none flex-shrink-0 opacity-0 group-hover/project-item:pointer-events-auto group-hover/project-item:opacity-100", - { - "pointer-events-auto opacity-100": isMenuActive, - } - )} - customButtonClassName="grid place-items-center" - placement="bottom-start" - ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")} - useCaptureForOutsideClick - closeOnSelect - onMenuClose={() => setIsMenuActive(false)} - > - {/* TODO: Removed is_favorite logic due to the optimization in projects API */} - {/* {isAuthorized && ( - - - - {project.is_favorite ? t("remove_from_favorites") : t("add_to_favorites")} + {!renderInToolbarMenu && ( + + - - )} */} - - {/* publish project settings */} - {isAdmin && ( - setPublishModal(true)}> -
-
- -
-
{t("publish_project")}
-
-
- )} - - - - {t("copy_link")} - - - {isAuthorized && ( - { - router.push(`/${workspaceSlug}/projects/${project?.id}/archives/issues`); - }} - > -
- - {t("archives")} -
-
- )} - { - router.push(`/${workspaceSlug}/settings/projects/${project?.id}`); - }} + } + className={cn( + "pointer-events-none flex-shrink-0 opacity-0 group-hover/project-item:pointer-events-auto group-hover/project-item:opacity-100", + { + "pointer-events-auto opacity-100": isMenuActive, + } + )} + customButtonClassName={cn( + "grid size-7 place-items-center rounded-full text-placeholder transition-colors hover:bg-layer-transparent-hover", + { + "bg-layer-transparent-hover": isMenuActive, + } + )} + placement="bottom-start" + menuItemsClassName={renderInToolbarMenu ? "z-[220]" : ""} + portalElement={renderInToolbarMenu && typeof document !== "undefined" ? document.body : undefined} + ariaLabel={t("aria_labels.projects_sidebar.toggle_quick_actions_menu")} + useCaptureForOutsideClick + closeOnSelect + menuButtonOnClick={() => setIsMenuActive((state) => !state)} + onMenuClose={() => setIsMenuActive(false)} > -
- - {t("settings")} -
-
- {/* leave project */} - {!isAuthorized && ( - -
- - {t("leave_project")} -
-
- )} -
+ {projectActionItems.map((item) => ( + +
+ {item.icon} + {item.label} +
+
+ ))} + + )} {isAccordionMode && (
+ {renderInToolbarMenu && projectActionItems.length > 0 && ( +
+ {projectActionItems.map((item) => ( + + ))} +
+ )} )} diff --git a/plane-src/apps/web/core/components/workspace/sidebar/sidebar-utility-rail.tsx b/plane-src/apps/web/core/components/workspace/sidebar/sidebar-utility-rail.tsx index 2967a3c..d528a08 100644 --- a/plane-src/apps/web/core/components/workspace/sidebar/sidebar-utility-rail.tsx +++ b/plane-src/apps/web/core/components/workspace/sidebar/sidebar-utility-rail.tsx @@ -6,18 +6,35 @@ import Link from "next/link"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -import { InboxIcon } from "@plane/propel/icons"; +import { useParams, usePathname } from "next/navigation"; +import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; +import { useTranslation } from "@plane/i18n"; +import { InboxIcon, PlusIcon } from "@plane/propel/icons"; import { Tooltip } from "@plane/propel/tooltip"; import useSWR from "swr"; import { TopNavPowerK } from "@/components/navigation"; +import { useCommandPalette } from "@/hooks/store/use-command-palette"; +import { useProject } from "@/hooks/store/use-project"; +import { useUserPermissions } from "@/hooks/store/user"; import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root"; import { useWorkspaceNotifications } from "@/hooks/store/notifications"; export const SidebarUtilityRail = observer(function SidebarUtilityRail() { const { workspaceSlug } = useParams(); + const pathname = usePathname(); + const { t } = useTranslation(); + const { toggleCreateIssueModal } = useCommandPalette(); + const { joinedProjectIds } = useProject(); + const { allowPermissions } = useUserPermissions(); const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications(); + const canCreateIssue = allowPermissions( + [EUserPermissions.ADMIN, EUserPermissions.MEMBER], + EUserPermissionsLevel.WORKSPACE + ); + const isCreateDisabled = joinedProjectIds.length === 0 || !canCreateIssue; + const isExternalContoursRoute = pathname?.includes("/external-contours"); + useSWR( workspaceSlug ? "WORKSPACE_UNREAD_NOTIFICATION_COUNT" : null, workspaceSlug ? () => getUnreadNotificationsCount(workspaceSlug.toString()) : null @@ -31,11 +48,24 @@ export const SidebarUtilityRail = observer(function SidebarUtilityRail() { return (
+ {!isExternalContoursRoute && ( + + + + )} {totalNotifications > 0 && ( diff --git a/plane-src/apps/web/core/hooks/use-dropdown.ts b/plane-src/apps/web/core/hooks/use-dropdown.ts index ab54a0a..5d8974c 100644 --- a/plane-src/apps/web/core/hooks/use-dropdown.ts +++ b/plane-src/apps/web/core/hooks/use-dropdown.ts @@ -61,7 +61,7 @@ export const useDropdown = (args: TArguments) => { * @description toggle the dropdown on click * @param {React.MouseEvent} e */ - const handleOnClick = (e: React.MouseEvent) => { + const handleOnClick = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); toggleDropdown(); diff --git a/plane-src/apps/web/core/store/inbox/inbox-issue.store.ts b/plane-src/apps/web/core/store/inbox/inbox-issue.store.ts index 49a7d78..88b62af 100644 --- a/plane-src/apps/web/core/store/inbox/inbox-issue.store.ts +++ b/plane-src/apps/web/core/store/inbox/inbox-issue.store.ts @@ -224,6 +224,7 @@ export class InboxIssueStore implements IInboxIssueStore { const issueKey = key as keyof TIssue; set(this.issue, issueKey, issue[issueKey]); }); + this.store.issue.issues.updateIssue(this.issue.id, issue); await this.inboxIssueService.updateIssue(this.workspaceSlug, this.projectId, this.issue.id, issue); // fetching activity this.fetchIssueActivity(); @@ -232,6 +233,7 @@ export class InboxIssueStore implements IInboxIssueStore { const issueKey = key as keyof TIssue; set(this.issue, issueKey, inboxIssue[issueKey]); }); + this.store.issue.issues.updateIssue(this.issue.id, inboxIssue); } }; @@ -243,6 +245,7 @@ export class InboxIssueStore implements IInboxIssueStore { const issueKey = key as keyof TIssue; set(this.issue, issueKey, issue[issueKey]); }); + this.store.issue.issues.updateIssue(this.issue.id, issue); await this.issueService.patchIssue(this.workspaceSlug, this.projectId, this.issue.id, issue); if (issue.cycle_id) { await this.store.issue.issueDetail.addIssueToCycle(this.workspaceSlug, this.projectId, issue.cycle_id, [ @@ -266,6 +269,7 @@ export class InboxIssueStore implements IInboxIssueStore { const issueKey = key as keyof TIssue; set(this.issue, issueKey, inboxIssue[issueKey]); }); + this.store.issue.issues.updateIssue(this.issue.id, inboxIssue); } }; diff --git a/plane-src/apps/web/core/store/inbox/project-inbox.store.ts b/plane-src/apps/web/core/store/inbox/project-inbox.store.ts index 6dc1f96..bbdeea1 100644 --- a/plane-src/apps/web/core/store/inbox/project-inbox.store.ts +++ b/plane-src/apps/web/core/store/inbox/project-inbox.store.ts @@ -85,6 +85,12 @@ export interface IProjectInboxStore { export class ProjectInboxStore implements IProjectInboxStore { // constants PER_PAGE_COUNT = 10; + DEFAULT_OPEN_STATUS_FILTERS = [EInboxIssueStatus.PENDING]; + DEFAULT_CLOSED_STATUS_FILTERS = [ + EInboxIssueStatus.ACCEPTED, + EInboxIssueStatus.DECLINED, + EInboxIssueStatus.DUPLICATE, + ]; // observables currentTab: TInboxIssueCurrentTab = EInboxIssueCurrentTab.OPEN; loader: TLoader = "init-loading"; @@ -148,36 +154,46 @@ export class ProjectInboxStore implements IProjectInboxStore { this.inboxFilters != undefined && Object.keys(this.inboxFilters).forEach((key) => { const filterKey = key as keyof TInboxIssueFilter; - if (this.inboxFilters[filterKey] && this.inboxFilters?.[filterKey]) - count = count + (this.inboxFilters?.[filterKey]?.length ?? 0); + const filterValues = this.inboxFilters?.[filterKey]; + if (!filterValues || filterValues.length === 0) return; + if ( + filterKey === "status" && + this.areStatusFiltersEqual(filterValues as TInboxIssueStatus[], this.DEFAULT_OPEN_STATUS_FILTERS) + ) + return; + count = count + filterValues.length; }); return count; } get filteredInboxIssueIds() { - let appliedFilters = - this.currentTab === EInboxIssueCurrentTab.OPEN - ? [EInboxIssueStatus.PENDING, EInboxIssueStatus.SNOOZED] - : [EInboxIssueStatus.ACCEPTED, EInboxIssueStatus.DECLINED, EInboxIssueStatus.DUPLICATE]; - appliedFilters = appliedFilters.filter((filter) => this.inboxFilters?.status?.includes(filter)); + const appliedFilters = this.getResolvedStatusFilters(); const currentTime = new Date().getTime(); - return this.currentTab === EInboxIssueCurrentTab.OPEN - ? this.inboxIssueIds.filter((id) => { - if (appliedFilters.length == 2) return true; - if (appliedFilters[0] === EInboxIssueStatus.SNOOZED) - return ( - this.inboxIssues[id].status === EInboxIssueStatus.SNOOZED && - currentTime < new Date(this.inboxIssues[id].snoozed_till!).getTime() - ); - if (appliedFilters[0] === EInboxIssueStatus.PENDING) - return ( - appliedFilters.includes(this.inboxIssues[id].status) || - (this.inboxIssues[id].status === EInboxIssueStatus.SNOOZED && - currentTime > new Date(this.inboxIssues[id].snoozed_till!).getTime()) - ); - }) - : this.inboxIssueIds.filter((id) => appliedFilters.includes(this.inboxIssues[id].status)); + return this.inboxIssueIds.filter((id) => { + const inboxIssue = this.inboxIssues[id]; + if (!inboxIssue) return false; + + return appliedFilters.some((filter) => { + if (filter === EInboxIssueStatus.SNOOZED) { + return ( + inboxIssue.status === EInboxIssueStatus.SNOOZED && + !!inboxIssue.snoozed_till && + currentTime < new Date(inboxIssue.snoozed_till).getTime() + ); + } + + if (filter === EInboxIssueStatus.PENDING) { + return ( + inboxIssue.status === EInboxIssueStatus.PENDING || + (inboxIssue.status === EInboxIssueStatus.SNOOZED && + (!inboxIssue.snoozed_till || currentTime > new Date(inboxIssue.snoozed_till).getTime())) + ); + } + + return inboxIssue.status === filter; + }); + }); } getIssueInboxByIssueId = computedFn((issueId: string) => this.inboxIssues?.[issueId]); @@ -242,6 +258,14 @@ export class ProjectInboxStore implements IProjectInboxStore { createOrUpdateInboxIssue = (inboxIssues: TInboxIssue[], workspaceSlug: string, projectId: string) => { if (inboxIssues && inboxIssues.length > 0) { + const mappedIssues = inboxIssues + .map((inboxIssue) => inboxIssue.issue) + .filter((issue): issue is TIssue => !!issue?.id && !!issue?.project_id); + + if (mappedIssues.length > 0) { + this.store.issue.issues.addIssue(mappedIssues); + } + inboxIssues.forEach((inbox: TInboxIssue) => { const existingInboxIssueDetail = this.getIssueInboxByIssueId(inbox?.issue?.id); if (existingInboxIssueDetail) @@ -258,6 +282,18 @@ export class ProjectInboxStore implements IProjectInboxStore { } }; + private getDefaultStatusFilters = (tab: TInboxIssueCurrentTab = this.currentTab) => + tab === EInboxIssueCurrentTab.CLOSED ? this.DEFAULT_CLOSED_STATUS_FILTERS : this.DEFAULT_OPEN_STATUS_FILTERS; + + private areStatusFiltersEqual = (left: TInboxIssueStatus[], right: TInboxIssueStatus[]) => + left.length === right.length && left.every((value) => right.includes(value)); + + private getResolvedStatusFilters = () => { + const filterValues = this.inboxFilters?.status; + if (filterValues && filterValues.length > 0) return filterValues as TInboxIssueStatus[]; + return this.getDefaultStatusFilters(); + }; + // actions handleCurrentTab = (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => { if (workspaceSlug && projectId) { @@ -267,10 +303,7 @@ export class ProjectInboxStore implements IProjectInboxStore { set(this, ["inboxIssuePaginationInfo"], undefined); set(this.sortingMap, [projectId], { order_by: "issue__created_at", sort_by: "desc" }); set(this.filtersMap, [projectId], { - status: - tab === EInboxIssueCurrentTab.OPEN - ? [EInboxIssueStatus.PENDING] - : [EInboxIssueStatus.ACCEPTED, EInboxIssueStatus.DECLINED, EInboxIssueStatus.DUPLICATE], + status: this.getDefaultStatusFilters(tab), }); }); this.fetchInboxIssues(workspaceSlug, projectId, "filter-loading"); @@ -305,10 +338,7 @@ export class ProjectInboxStore implements IProjectInboxStore { if (!projectId || !tab) return; if (isEmpty(this.inboxFilters)) { set(this.filtersMap, [projectId], { - status: - tab === EInboxIssueCurrentTab.OPEN - ? [EInboxIssueStatus.PENDING] - : [EInboxIssueStatus.ACCEPTED, EInboxIssueStatus.DECLINED, EInboxIssueStatus.DUPLICATE], + status: this.getDefaultStatusFilters(tab), }); } if (isEmpty(this.inboxSorting)) { diff --git a/plane-src/apps/web/styles/globals.css b/plane-src/apps/web/styles/globals.css index 3bab96f..dfe68de 100644 --- a/plane-src/apps/web/styles/globals.css +++ b/plane-src/apps/web/styles/globals.css @@ -279,6 +279,13 @@ border-color: transparent !important; } + .nodedc-bottom-dock-left { + max-width: min(22rem, 26vw) !important; + flex-grow: 0 !important; + flex-shrink: 1 !important; + overflow: hidden; + } + .nodedc-glass-modal [data-slot="button"], .nodedc-glass-modal [data-slot="icon-button"] { border: none !important; diff --git a/plane-src/packages/ui/src/breadcrumbs/navigation-search-dropdown.tsx b/plane-src/packages/ui/src/breadcrumbs/navigation-search-dropdown.tsx index 8f270e9..598064e 100644 --- a/plane-src/packages/ui/src/breadcrumbs/navigation-search-dropdown.tsx +++ b/plane-src/packages/ui/src/breadcrumbs/navigation-search-dropdown.tsx @@ -6,7 +6,6 @@ import * as React from "react"; import { useState } from "react"; -import { Tooltip } from "@plane/propel/tooltip"; import type { ICustomSearchSelectOption } from "@plane/types"; import { CustomSearchSelect } from "../dropdowns"; import { cn } from "../utils"; @@ -23,6 +22,9 @@ type TBreadcrumbNavigationSearchDropdownProps = { handleOnClick?: () => void; disableRootHover?: boolean; shouldTruncate?: boolean; + openOnLabelClick?: boolean; + rotateChevronWhenLast?: boolean; + showLastChevron?: boolean; }; export function BreadcrumbNavigationSearchDropdown(props: TBreadcrumbNavigationSearchDropdownProps) { @@ -36,9 +38,13 @@ export function BreadcrumbNavigationSearchDropdown(props: TBreadcrumbNavigationS isLast = false, handleOnClick, shouldTruncate = false, + openOnLabelClick = false, + rotateChevronWhenLast = true, + showLastChevron = true, } = props; // state const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const shouldOpenOnItemClick = openOnLabelClick || !handleOnClick; return ( - - - - + {icon && {icon}} + {title} +
+
+ {(!isLast || showLastChevron) && ( + + )} } disabled={navigationDisabled} diff --git a/plane-src/packages/ui/src/control-link/control-link.tsx b/plane-src/packages/ui/src/control-link/control-link.tsx index 39249fb..1e20645 100644 --- a/plane-src/packages/ui/src/control-link/control-link.tsx +++ b/plane-src/packages/ui/src/control-link/control-link.tsx @@ -24,6 +24,14 @@ export const ControlLink = React.forwardRef(function ControlLink( const LEFT_CLICK_EVENT_CODE = 0; const handleOnClick = (event: React.MouseEvent) => { + const interactiveTarget = (event.target as HTMLElement | null)?.closest( + '[data-control-link-ignore="true"],button,[role="button"],input,select,textarea,[contenteditable="true"]' + ); + if (interactiveTarget && interactiveTarget !== event.currentTarget) { + event.preventDefault(); + return; + } + const clickCondition = (event.metaKey || event.ctrlKey) && event.button === LEFT_CLICK_EVENT_CODE; if (!clickCondition) { event.preventDefault(); diff --git a/plane-src/packages/ui/src/dropdowns/action-dropdown.tsx b/plane-src/packages/ui/src/dropdowns/action-dropdown.tsx new file mode 100644 index 0000000..cdf12b8 --- /dev/null +++ b/plane-src/packages/ui/src/dropdowns/action-dropdown.tsx @@ -0,0 +1,254 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import * as React from "react"; +import ReactDOM from "react-dom"; +import { usePopper } from "react-popper"; +import { MoreHorizontal } from "lucide-react"; +import { useOutsideClickDetector } from "@plane/hooks"; +import { ChevronRightIcon } from "@plane/propel/icons"; +import type { TPlacement } from "@plane/propel/utils/placement"; +import { useDropdownKeyDown } from "../hooks/use-dropdown-key-down"; +import { cn } from "../utils"; +import type { TContextMenuItem } from "./context-menu"; + +export type TActionDropdownTrigger = React.ReactNode; + +export interface IActionDropdownProps { + button?: TActionDropdownTrigger; + buttonClassName?: string; + className?: string; + items: TContextMenuItem[]; + menuClassName?: string; + onOpenChange?: (isOpen: boolean) => void; + placement?: TPlacement; + portalElement?: Element | null; +} + +const renderActionContent = (item: TContextMenuItem) => + item.customContent ?? ( +
+ {item.icon && } +
+ {item.title &&
{item.title}
} + {item.description && ( +

+ {item.description} +

+ )} +
+
+ ); + +type TActionDropdownItemProps = { + item: TContextMenuItem; + onSelect: () => void; +}; + +function ActionDropdownItem(props: TActionDropdownItemProps) { + const { item, onSelect } = props; + const nestedItems = item.nestedMenuItems?.filter((nestedItem) => nestedItem.shouldRender !== false) ?? []; + const hasNestedItems = nestedItems.length > 0; + const [isNestedOpen, setIsNestedOpen] = React.useState(false); + + if (item.shouldRender === false) return null; + + const handleClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (item.disabled) return; + + if (hasNestedItems) { + setIsNestedOpen((prev) => !prev); + return; + } + + item.action(); + onSelect(); + }; + + return ( +
+ + + {hasNestedItems && isNestedOpen && ( +
+ {nestedItems.map((nestedItem) => ( + + ))} +
+ )} +
+ ); +} + +export function ActionDropdown(props: IActionDropdownProps) { + const { button, buttonClassName, className, items, menuClassName, onOpenChange, placement, portalElement } = props; + const dropdownRef = React.useRef(null); + const [referenceElement, setReferenceElement] = React.useState(null); + const [popperElement, setPopperElement] = React.useState(null); + const [isOpen, setIsOpen] = React.useState(false); + + const renderedItems = items.filter((item) => item.shouldRender !== false); + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "bottom-end", + strategy: "fixed", + modifiers: [ + { + name: "offset", + options: { + offset: [0, 8], + }, + }, + { + name: "flip", + options: { + fallbackPlacements: ["bottom-end", "bottom-start", "top-end", "top-start"], + }, + }, + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + + const openDropdown = React.useCallback(() => { + if (renderedItems.length === 0) return; + setIsOpen(true); + onOpenChange?.(true); + }, [onOpenChange, renderedItems.length]); + + const closeDropdown = React.useCallback(() => { + if (!isOpen) return; + setIsOpen(false); + onOpenChange?.(false); + }, [isOpen, onOpenChange]); + + const handleTriggerClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + if (renderedItems.length === 0) return; + + if (isOpen) { + closeDropdown(); + return; + } + + openDropdown(); + }; + + const handleKeyDown = useDropdownKeyDown(openDropdown, closeDropdown, isOpen); + + useOutsideClickDetector(dropdownRef, closeDropdown); + + const defaultButton = ( + + ); + + const customButton = button ? ( + + ) : null; + + const popup = + isOpen && typeof document !== "undefined" + ? ReactDOM.createPortal( +
+
+
+ {renderedItems.map((item) => ( + + ))} +
+
+
, + portalElement ?? document.body + ) + : null; + + return ( +
+ {customButton ?? defaultButton} + {popup} +
+ ); +} diff --git a/plane-src/packages/ui/src/dropdowns/custom-menu.tsx b/plane-src/packages/ui/src/dropdowns/custom-menu.tsx index a0f5665..d741381 100644 --- a/plane-src/packages/ui/src/dropdowns/custom-menu.tsx +++ b/plane-src/packages/ui/src/dropdowns/custom-menu.tsx @@ -86,7 +86,7 @@ function CustomMenu(props: ICustomMenuDropdownProps) { useCaptureForOutsideClick = false, } = props; - const [referenceElement, setReferenceElement] = React.useState(null); + const [referenceElement, setReferenceElement] = React.useState(null); const [popperElement, setPopperElement] = React.useState(null); const [isOpen, setIsOpen] = React.useState(false); // refs @@ -95,6 +95,27 @@ function CustomMenu(props: ICustomMenuDropdownProps) { const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: placement ?? "auto", + strategy: "fixed", + modifiers: [ + { + name: "offset", + options: { + offset: [0, 8], + }, + }, + { + name: "flip", + options: { + fallbackPlacements: ["bottom-end", "bottom-start", "top-end", "top-start"], + }, + }, + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], }); const closeAllSubmenus = React.useCallback(() => { @@ -134,6 +155,9 @@ function CustomMenu(props: ICustomMenuDropdownProps) { if (closeOnSelect) closeDropdown(); }; + const shouldIgnoreContainerClick = (target: EventTarget | null) => + !!(target instanceof Element && target.closest('[data-custom-menu-trigger="true"]')); + const handleMenuButtonClick = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); @@ -145,6 +169,23 @@ function CustomMenu(props: ICustomMenuDropdownProps) { if (menuButtonOnClick) menuButtonOnClick(); }; + const customButtonElement = + customButton && React.isValidElement(customButton) && typeof customButton.type === "string" + ? React.cloneElement(customButton, { + ref: setReferenceElement, + onClick: (e: React.MouseEvent) => { + customButton.props.onClick?.(e); + handleMenuButtonClick(e as React.MouseEvent); + }, + className: cn(customButton.props.className, customButtonClassName), + tabIndex: disabled ? -1 : customButton.props.tabIndex ?? customButtonTabIndex, + role: customButton.props.role ?? (customButton.type === "button" ? undefined : "button"), + "aria-label": ariaLabel, + "aria-disabled": disabled || undefined, + "data-custom-menu-trigger": "true", + }) + : null; + const handleMouseEnter = () => { if (openOnHover) openDropdown(); }; @@ -232,6 +273,7 @@ function CustomMenu(props: ICustomMenuDropdownProps) { onClick={(e) => { e.stopPropagation(); e.preventDefault(); + if (shouldIgnoreContainerClick(e.target)) return; handleOnClick(); }} onMouseEnter={handleMouseEnter} @@ -242,23 +284,27 @@ function CustomMenu(props: ICustomMenuDropdownProps) { <> {customButton ? ( - + {customButtonElement ?? ( + + )} ) : ( <> {ellipsis || verticalEllipsis ? (