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

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

View File

@@ -0,0 +1,22 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Outlet } from "react-router";
// components
import { AppHeader } from "@/components/core/app-header";
import { ContentWrapper } from "@/components/core/content-wrapper";
import { ProjectArchivesHeader } from "../header";
export default function ProjectArchiveCyclesLayout() {
return (
<>
<AppHeader header={<ProjectArchivesHeader activeTab="cycles" />} />
<ContentWrapper>
<Outlet />
</ContentWrapper>
</>
);
}

View File

@@ -0,0 +1,36 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// components
import { PageHead } from "@/components/core/page-title";
import { ArchivedCycleLayoutRoot } from "@/components/cycles/archived-cycles";
import { ArchivedCyclesHeader } from "@/components/cycles/archived-cycles/header";
// hooks
import { useProject } from "@/hooks/store/use-project";
import type { Route } from "./+types/page";
function ProjectArchivedCyclesPage({ params }: Route.ComponentProps) {
// router
const { projectId } = params;
// store hooks
const { getProjectById } = useProject();
// derived values
const project = getProjectById(projectId);
const pageTitle = project?.name && `${project?.name} - Archived cycles`;
return (
<>
<PageHead title={pageTitle} />
<div className="relative flex h-full w-full flex-col overflow-hidden">
<ArchivedCyclesHeader />
<ArchivedCycleLayoutRoot />
</div>
</>
);
}
export default observer(ProjectArchivedCyclesPage);

View File

@@ -0,0 +1,112 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { ArchiveOutline, CyclesOutline, ModuleOutline, WorkItemsOutline } from "@makeplane/propel/icons";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import { EIssuesStoreType } from "@plane/types";
// ui
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/components/breadcrumbs/common";
type TProps = {
activeTab: "issues" | "cycles" | "modules";
};
const PROJECT_ARCHIVES_BREADCRUMB_LIST: {
[key: string]: {
label: string;
href: string;
icon: React.FC<React.SVGAttributes<SVGElement> & { className?: string }>;
};
} = {
issues: {
label: "Work items",
href: "/issues",
icon: WorkItemsOutline,
},
cycles: {
label: "Cycles",
href: "/cycles",
icon: CyclesOutline,
},
modules: {
label: "Modules",
href: "/modules",
icon: ModuleOutline,
},
};
export const ProjectArchivesHeader = observer(function ProjectArchivesHeader(props: TProps) {
const { activeTab } = props;
// router
const router = useAppRouter();
const { workspaceSlug, projectId } = useParams();
// store hooks
const {
issues: { getGroupIssueCount },
} = useIssues(EIssuesStoreType.ARCHIVED);
const { loader } = useProject();
// hooks
const { isMobile } = usePlatformOS();
const issueCount = getGroupIssueCount(undefined, undefined, false);
const activeTabBreadcrumbDetail =
PROJECT_ARCHIVES_BREADCRUMB_LIST[activeTab as keyof typeof PROJECT_ARCHIVES_BREADCRUMB_LIST];
return (
<Header>
<Header.LeftItem>
<div className="flex items-center gap-2.5">
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
href={`/${workspaceSlug}/projects/${projectId}/archives/issues`}
label="Archives"
icon={<ArchiveOutline className="h-4 w-4 text-tertiary" />}
/>
}
/>
{activeTabBreadcrumbDetail && (
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={activeTabBreadcrumbDetail.label}
icon={<activeTabBreadcrumbDetail.icon className="h-4 w-4 text-tertiary" />}
/>
}
/>
)}
</Breadcrumbs>
{activeTab === "issues" && issueCount && issueCount > 0 ? (
<Tooltip
label={`There are ${issueCount} ${issueCount > 1 ? "work items" : "work item"} in project's archived`}
layout="stacked"
side="bottom"
disabled={isMobile}
>
<span className="flex flex-shrink-0 cursor-default items-center justify-center rounded-xl bg-accent-primary/20 px-2.5 py-0.5 text-center text-11 font-semibold text-accent-primary">
{issueCount}
</span>
</Tooltip>
) : null}
</div>
</Header.LeftItem>
</Header>
);
});

View File

@@ -0,0 +1,102 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
import useSWR from "swr";
// ui
import { Banner } from "@makeplane/propel/components/banner";
import { ArchiveOutline } from "@makeplane/propel/icons";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { Loader } from "@plane/ui";
// components
import { PageHead } from "@/components/core/page-title";
import { IssueDetailRoot } from "@/components/issues/issue-detail";
// constants
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useProject } from "@/hooks/store/use-project";
import type { Route } from "./+types/page";
function ArchivedIssueDetailsPage({ params }: Route.ComponentProps) {
// router
const { workspaceSlug, projectId, archivedIssueId } = params;
const router = useRouter();
// states
// hooks
const { t } = useTranslation();
const {
fetchIssue,
issue: { getIssueById },
} = useIssueDetail();
const { getProjectById } = useProject();
const { isLoading } = useSWR(`ARCHIVED_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${archivedIssueId}`, () =>
fetchIssue(workspaceSlug, projectId, archivedIssueId)
);
// derived values
const issue = getIssueById(archivedIssueId);
const project = issue ? getProjectById(issue?.project_id ?? "") : undefined;
const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined;
if (!issue) return <></>;
const issueLoader = !issue || isLoading;
return (
<>
<PageHead title={pageTitle} />
{issueLoader ? (
<Loader className="flex h-full gap-5 p-5">
<div className="basis-2/3 space-y-2">
<Loader.Item height="30px" width="40%" />
<Loader.Item height="15px" width="60%" />
<Loader.Item height="15px" width="60%" />
<Loader.Item height="15px" width="40%" />
</div>
<div className="basis-1/3 space-y-3">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</div>
</Loader>
) : (
<>
<Banner
placement="page"
variant="warning"
title={t("issue.archive.banner_message")}
icon={<ArchiveOutline />}
actions={
<Button
variant="secondary"
onClick={() => router.push(`/${workspaceSlug}/projects/${projectId}/archives/issues/`)}
>
{t("issue.archive.go_to_archives")}
</Button>
}
/>
<div className="flex h-full overflow-hidden">
<div className="h-full w-full space-y-3 divide-y-2 divide-subtle-1 overflow-y-auto">
<IssueDetailRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
issueId={archivedIssueId}
is_archived
/>
</div>
</div>
</>
)}
</>
);
}
export default observer(ArchivedIssueDetailsPage);

View File

@@ -0,0 +1,85 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import useSWR from "swr";
// ui
import { ArchiveOutline, WorkItemsOutline } from "@makeplane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { IssueDetailQuickActions } from "@/components/issues/issue-detail/issue-detail-quick-actions";
// constants
import { ISSUE_DETAILS } from "@plane/constants";
// hooks
import { useProject } from "@/hooks/store/use-project";
// plane web
import { ProjectBreadcrumb } from "@/components/breadcrumbs/project";
// services
import { IssueService } from "@/services/issue";
const issueService = new IssueService();
export const ProjectArchivedIssueDetailsHeader = observer(function ProjectArchivedIssueDetailsHeader() {
// router
const { workspaceSlug, projectId, archivedIssueId } = useParams();
// store hooks
const { currentProjectDetails, loader } = useProject();
const { data: issueDetails } = useSWR(
workspaceSlug && projectId && archivedIssueId ? ISSUE_DETAILS(archivedIssueId.toString()) : null,
workspaceSlug && projectId && archivedIssueId
? () => issueService.retrieve(workspaceSlug.toString(), projectId.toString(), archivedIssueId.toString())
: null
);
return (
<Header>
<Header.LeftItem>
<Breadcrumbs isLoading={loader === "init-loader"}>
<ProjectBreadcrumb workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
href={`/${workspaceSlug}/projects/${projectId}/archives/issues`}
label="Archives"
icon={<ArchiveOutline className="h-4 w-4 text-tertiary" />}
/>
}
/>
<Breadcrumbs.Item
component={
<BreadcrumbLink
href={`/${workspaceSlug}/projects/${projectId}/archives/issues`}
label="Work items"
icon={<WorkItemsOutline className="h-4 w-4 text-tertiary" />}
/>
}
/>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={
currentProjectDetails && issueDetails
? `${currentProjectDetails.identifier}-${issueDetails.sequence_id}`
: ""
}
/>
}
/>
</Breadcrumbs>
</Header.LeftItem>
<Header.RightItem>
<IssueDetailQuickActions
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
issueId={archivedIssueId.toString()}
/>
</Header.RightItem>
</Header>
);
});

View File

@@ -0,0 +1,22 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Outlet } from "react-router";
// components
import { AppHeader } from "@/components/core/app-header";
import { ContentWrapper } from "@/components/core/content-wrapper";
import { ProjectArchivedIssueDetailsHeader } from "./header";
export default function ProjectArchivedIssueDetailLayout() {
return (
<>
<AppHeader header={<ProjectArchivedIssueDetailsHeader />} />
<ContentWrapper>
<Outlet />
</ContentWrapper>
</>
);
}

View File

@@ -0,0 +1,22 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Outlet } from "react-router";
// components
import { AppHeader } from "@/components/core/app-header";
import { ContentWrapper } from "@/components/core/content-wrapper";
import { ProjectArchivesHeader } from "../../header";
export default function ProjectArchiveIssuesLayout() {
return (
<>
<AppHeader header={<ProjectArchivesHeader activeTab="issues" />} />
<ContentWrapper>
<Outlet />
</ContentWrapper>
</>
);
}

View File

@@ -0,0 +1,36 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// components
import { PageHead } from "@/components/core/page-title";
import { ArchivedIssuesHeader } from "@/components/issues/archived-issues-header";
import { ArchivedIssueLayoutRoot } from "@/components/issues/issue-layouts/roots/archived-issue-layout-root";
// hooks
import { useProject } from "@/hooks/store/use-project";
import type { Route } from "./+types/page";
function ProjectArchivedIssuesPage({ params }: Route.ComponentProps) {
// router
const { projectId } = params;
// store hooks
const { getProjectById } = useProject();
// derived values
const project = getProjectById(projectId);
const pageTitle = project?.name && `${project?.name} - Archived work items`;
return (
<>
<PageHead title={pageTitle} />
<div className="relative flex h-full w-full flex-col overflow-hidden">
<ArchivedIssuesHeader />
<ArchivedIssueLayoutRoot />
</div>
</>
);
}
export default observer(ProjectArchivedIssuesPage);

View File

@@ -0,0 +1,22 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Outlet } from "react-router";
// components
import { AppHeader } from "@/components/core/app-header";
import { ContentWrapper } from "@/components/core/content-wrapper";
import { ProjectArchivesHeader } from "../header";
export default function ProjectArchiveModulesLayout() {
return (
<>
<AppHeader header={<ProjectArchivesHeader activeTab="modules" />} />
<ContentWrapper>
<Outlet />
</ContentWrapper>
</>
);
}

View File

@@ -0,0 +1,35 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// components
import { PageHead } from "@/components/core/page-title";
import { ArchivedModuleLayoutRoot, ArchivedModulesHeader } from "@/components/modules";
// hooks
import { useProject } from "@/hooks/store/use-project";
import type { Route } from "./+types/page";
function ProjectArchivedModulesPage({ params }: Route.ComponentProps) {
// router
const { projectId } = params;
// store hooks
const { getProjectById } = useProject();
// derived values
const project = getProjectById(projectId);
const pageTitle = project?.name && `${project?.name} - Archived modules`;
return (
<>
<PageHead title={pageTitle} />
<div className="relative flex h-full w-full flex-col overflow-hidden">
<ArchivedModulesHeader />
<ArchivedModuleLayoutRoot />
</div>
</>
);
}
export default observer(ProjectArchivedModulesPage);

View File

@@ -0,0 +1,93 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { cn } from "@plane/utils";
// assets
import emptyCycle from "@/app/assets/empty-state/cycle.svg?url";
// components
import { EmptyState } from "@/components/common/empty-state";
import { PageHead } from "@/components/core/page-title";
import useCyclesDetails from "@/components/cycles/active-cycle/use-cycles-details";
import { CycleDetailsSidebar } from "@/components/cycles/analytics-sidebar";
import { CycleLayoutRoot } from "@/components/issues/issue-layouts/roots/cycle-layout-root";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
import useLocalStorage from "@/hooks/use-local-storage";
import type { Route } from "./+types/page";
function CycleDetailPage({ params }: Route.ComponentProps) {
// router
const router = useAppRouter();
const { workspaceSlug, projectId, cycleId } = params;
// store hooks
const { getCycleById, loader } = useCycle();
const { getProjectById } = useProject();
// const { issuesFilter } = useIssues(EIssuesStoreType.CYCLE);
// hooks
const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", false);
useCyclesDetails({
workspaceSlug,
projectId,
cycleId,
});
// derived values
const isSidebarCollapsed = storedValue ? (storedValue === true ? true : false) : false;
const cycle = getCycleById(cycleId);
const project = getProjectById(projectId);
const pageTitle = project?.name && cycle?.name ? `${project?.name} - ${cycle?.name}` : undefined;
/**
* Toggles the sidebar
*/
const toggleSidebar = () => setValue(!isSidebarCollapsed);
// const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
return (
<>
<PageHead title={pageTitle} />
{!cycle && !loader ? (
<EmptyState
image={emptyCycle}
title="Cycle does not exist"
description="The cycle you are looking for does not exist or has been deleted."
primaryButton={{
text: "View other cycles",
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/cycles`),
}}
/>
) : (
<>
<div className="flex h-full w-full">
<div className="h-full w-full overflow-hidden">
<CycleLayoutRoot />
</div>
{!isSidebarCollapsed && (
<div
className={cn(
"vertical-scrollbar absolute right-0 z-13 scrollbar-sm flex h-full w-[21.5rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-subtle bg-surface-1 px-4 shadow-raised-200 duration-300"
)}
>
<CycleDetailsSidebar
handleClose={toggleSidebar}
cycleId={cycleId}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
</div>
)}
</div>
</>
)}
</>
);
}
export default observer(CycleDetailPage);

View File

@@ -0,0 +1,273 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// icons
import { BarOutline, CyclesOutline, PreferencesOutline, RightSidePaneOutline } from "@makeplane/propel/icons";
// plane imports
import {
EIssueFilterType,
EUserPermissions,
EUserPermissionsLevel,
ISSUE_DISPLAY_FILTERS_BY_PAGE,
} from "@plane/constants";
import { usePlatformOS } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { IconButton } from "@plane/propel/icon-button";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import type { ICustomSearchSelectOption, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
import { Breadcrumbs, BreadcrumbNavigationSearchDropdown, Header } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SwitcherLabel } from "@/components/common/switcher-label";
import { CycleQuickActions } from "@/components/cycles/quick-actions";
import {
DisplayFiltersSelection,
FiltersDropdown,
LayoutSelection,
MobileLayoutSelection,
} from "@/components/issues/issue-layouts/filters";
import { WorkItemFiltersToggle } from "@/components/work-item-filters/filters-toggle";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useCycle } from "@/hooks/store/use-cycle";
import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import useLocalStorage from "@/hooks/use-local-storage";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/components/breadcrumbs/common";
export const CycleIssuesHeader = observer(function CycleIssuesHeader() {
// refs
const parentRef = useRef<HTMLDivElement>(null);
// states
const [analyticsModal, setAnalyticsModal] = useState(false);
// router
const router = useAppRouter();
const { workspaceSlug, projectId, cycleId } = useParams();
// i18n
const { t } = useTranslation();
// store hooks
const {
issuesFilter: { issueFilters, updateFilters },
issues: { getGroupIssueCount },
} = useIssues(EIssuesStoreType.CYCLE);
const { currentProjectCycleIds, getCycleById } = useCycle();
const { toggleCreateIssueModal } = useCommandPalette();
const { currentProjectDetails, loader } = useProject();
const { isMobile } = usePlatformOS();
const { allowPermissions } = useUserPermissions();
const activeLayout = issueFilters?.displayFilters?.layout;
const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", false);
const isSidebarCollapsed = storedValue === true;
const toggleSidebar = () => {
setValue(!isSidebarCollapsed);
};
const handleLayoutChange = useCallback(
(layout: EIssueLayoutTypes) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, cycleId);
},
[workspaceSlug, projectId, cycleId, updateFilters]
);
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, cycleId);
},
[workspaceSlug, projectId, cycleId, updateFilters]
);
const handleDisplayProperties = useCallback(
(property: Partial<IIssueDisplayProperties>) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property, cycleId);
},
[workspaceSlug, projectId, cycleId, updateFilters]
);
// derived values
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
const isCompletedCycle = cycleDetails?.status?.toLocaleLowerCase() === "completed";
const canUserCreateIssue = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
const switcherOptions = currentProjectCycleIds
?.map((id) => {
const _cycle = id === cycleId ? cycleDetails : getCycleById(id);
if (!_cycle) return;
return {
value: _cycle.id,
query: _cycle.name,
content: <SwitcherLabel name={_cycle.name} LabelIcon={CyclesOutline} />,
};
})
.filter((option) => option !== undefined) as ICustomSearchSelectOption[];
const workItemsCount = getGroupIssueCount(undefined, undefined, false);
return (
<>
<WorkItemsModal
projectDetails={currentProjectDetails}
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
cycleDetails={cycleDetails ?? undefined}
/>
<Header>
<Header.LeftItem>
<div className="flex items-center gap-2">
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Cycles"
href={`/${workspaceSlug}/projects/${projectId}/cycles/`}
icon={<CyclesOutline className="h-4 w-4 text-tertiary" />}
/>
}
/>
<Breadcrumbs.Item
component={
<BreadcrumbNavigationSearchDropdown
selectedItem={cycleId}
navigationItems={switcherOptions}
onChange={(value: string) => {
router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${value}`);
}}
title={cycleDetails?.name}
icon={
<Breadcrumbs.Icon>
<CyclesOutline className="size-4 flex-shrink-0 text-tertiary" />
</Breadcrumbs.Icon>
}
isLast
/>
}
isLast
/>
</Breadcrumbs>
{workItemsCount && workItemsCount > 0 ? (
<Tooltip
label={`There are ${workItemsCount} ${workItemsCount > 1 ? "work items" : "work item"} in this cycle`}
layout="stacked"
side="bottom"
disabled={isMobile}
>
<span className="flex flex-shrink-0 cursor-default items-center justify-center rounded-xl bg-accent-primary/20 px-2 text-center text-11 font-semibold text-accent-primary">
{workItemsCount}
</span>
</Tooltip>
) : null}
</div>
</Header.LeftItem>
<Header.RightItem className="items-center">
<div className="hidden items-center gap-2 md:flex">
<div className="hidden @4xl:flex">
<LayoutSelection
layouts={[
EIssueLayoutTypes.LIST,
EIssueLayoutTypes.KANBAN,
EIssueLayoutTypes.CALENDAR,
EIssueLayoutTypes.SPREADSHEET,
EIssueLayoutTypes.GANTT,
]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
</div>
<div className="flex @4xl:hidden">
<MobileLayoutSelection
layouts={[
EIssueLayoutTypes.LIST,
EIssueLayoutTypes.KANBAN,
EIssueLayoutTypes.CALENDAR,
EIssueLayoutTypes.SPREADSHEET,
EIssueLayoutTypes.GANTT,
]}
onChange={(layout) => handleLayoutChange(layout)}
activeLayout={activeLayout}
/>
</div>
<WorkItemFiltersToggle entityType={EIssuesStoreType.CYCLE} entityId={cycleId} />
<FiltersDropdown
title={t("common.display")}
placement="bottom-end"
miniIcon={<PreferencesOutline className="size-3.5" />}
>
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
ignoreGroupedFilters={["cycle"]}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
/>
</FiltersDropdown>
{canUserCreateIssue && (
<>
<Button onClick={() => setAnalyticsModal(true)} variant="secondary" size="lg">
<span className="hidden @4xl:flex">Analytics</span>
<span className="@4xl:hidden">
<BarOutline className="size-3.5" />
</span>
</Button>
{!isCompletedCycle && (
<Button
variant="primary"
size="lg"
onClick={() => {
toggleCreateIssueModal(true, EIssuesStoreType.CYCLE);
}}
>
{t("issue.add.label")}
</Button>
)}
</>
)}
<IconButton
variant="tertiary"
size="lg"
icon={RightSidePaneOutline}
onClick={toggleSidebar}
className={cn({
"bg-accent-subtle text-accent-primary": !isSidebarCollapsed,
})}
/>
<CycleQuickActions
parentRef={parentRef}
cycleId={cycleId}
projectId={projectId}
workspaceSlug={workspaceSlug}
customClassName="flex-shrink-0 flex items-center justify-center size-[26px] bg-layer-1/70 rounded-sm"
/>
</div>
</Header.RightItem>
</Header>
</>
);
});

View File

@@ -0,0 +1,23 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Outlet } from "react-router";
// components
import { AppHeader } from "@/components/core/app-header";
import { ContentWrapper } from "@/components/core/content-wrapper";
import { CycleIssuesHeader } from "./header";
import { CycleIssuesMobileHeader } from "./mobile-header";
export default function ProjectCycleIssuesLayout() {
return (
<>
<AppHeader header={<CycleIssuesHeader />} mobileHeader={<CycleIssuesMobileHeader />} />
<ContentWrapper>
<Outlet />
</ContentWrapper>
</>
);
}

View File

@@ -0,0 +1,158 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { BoardOutline, CalendarOutline, ChevronDownOutline, ListOutline } from "@makeplane/propel/icons";
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
import { CustomMenu } from "@plane/ui";
// components
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
import { IssueLayoutIcon } from "@/components/issues/issue-layouts/layout-icon";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
const SUPPORTED_LAYOUTS = [
{ key: "list", titleTranslationKey: "issue.layouts.list", icon: ListOutline },
{ key: "kanban", titleTranslationKey: "issue.layouts.kanban", icon: BoardOutline },
{ key: "calendar", titleTranslationKey: "issue.layouts.calendar", icon: CalendarOutline },
];
export const CycleIssuesMobileHeader = observer(function CycleIssuesMobileHeader() {
// router
const { workspaceSlug, projectId, cycleId } = useParams();
// states
const [analyticsModal, setAnalyticsModal] = useState(false);
// plane hooks
const { t } = useTranslation();
// store hooks
const { currentProjectDetails } = useProject();
const { getCycleById } = useCycle();
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.CYCLE);
// derived values
const activeLayout = issueFilters?.displayFilters?.layout;
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
const handleLayoutChange = useCallback(
(layout: EIssueLayoutTypes) => {
if (!workspaceSlug || !projectId || !cycleId) return;
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.DISPLAY_FILTERS,
{ layout: layout },
cycleId.toString()
);
},
[workspaceSlug, projectId, cycleId, updateFilters]
);
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !projectId || !cycleId) return;
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.DISPLAY_FILTERS,
updatedDisplayFilter,
cycleId.toString()
);
},
[workspaceSlug, projectId, cycleId, updateFilters]
);
const handleDisplayProperties = useCallback(
(property: Partial<IIssueDisplayProperties>) => {
if (!workspaceSlug || !projectId || !cycleId) return;
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.DISPLAY_PROPERTIES,
property,
cycleId.toString()
);
},
[workspaceSlug, projectId, cycleId, updateFilters]
);
return (
<>
<WorkItemsModal
projectDetails={currentProjectDetails}
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
cycleDetails={cycleDetails ?? undefined}
/>
<div className="flex justify-evenly border-b border-subtle bg-surface-1 py-2 md:hidden">
<CustomMenu
maxHeight={"md"}
className="flex flex-grow justify-center text-13 text-secondary"
placement="bottom-start"
customButton={
<span className="flex flex-grow justify-center text-13 text-secondary">{t("common.layout")}</span>
}
customButtonClassName="flex flex-grow justify-center text-secondary text-13"
closeOnSelect
>
{SUPPORTED_LAYOUTS.map((layout, index) => (
<CustomMenu.MenuItem
key={ISSUE_LAYOUTS[index].key}
onClick={() => {
handleLayoutChange(ISSUE_LAYOUTS[index].key);
}}
className="flex items-center gap-2"
>
<IssueLayoutIcon layout={ISSUE_LAYOUTS[index].key} className="h-3 w-3" />
<div className="text-tertiary">{t(layout.titleTranslationKey)}</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
<div className="flex flex-grow items-center justify-center border-l border-subtle text-13 text-secondary">
<FiltersDropdown
title={t("common.display")}
placement="bottom-end"
menuButton={
<span className="flex items-center text-13 text-secondary">
{t("common.display")}
<ChevronDownOutline className="ml-2 h-4 w-4 text-secondary" />
</span>
}
>
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
ignoreGroupedFilters={["cycle"]}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
/>
</FiltersDropdown>
</div>
<span
onClick={() => setAnalyticsModal(true)}
className="flex flex-grow justify-center border-l border-subtle text-13 text-secondary"
>
{t("common.analytics")}
</span>
</div>
</>
);
});

View File

@@ -0,0 +1,79 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// ui
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { CyclesOutline } from "@makeplane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { CyclesViewHeader } from "@/components/cycles/cycles-view-header";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/components/breadcrumbs/common";
export const CyclesListHeader = observer(function CyclesListHeader() {
// router
const router = useAppRouter();
const { workspaceSlug, projectId } = useParams();
// store hooks
const { toggleCreateCycleModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
const { currentProjectDetails, loader } = useProject();
const { t } = useTranslation();
const canUserCreateCycle = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
return (
<Header>
<Header.LeftItem>
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Cycles"
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/cycles/`}
icon={<CyclesOutline className="h-4 w-4 text-tertiary" />}
isLast
/>
}
isLast
/>
</Breadcrumbs>
</Header.LeftItem>
{canUserCreateCycle && currentProjectDetails ? (
<Header.RightItem>
<CyclesViewHeader projectId={currentProjectDetails.id} />
<Button
variant="primary"
size="lg"
onClick={() => {
toggleCreateCycleModal(true);
}}
>
<div className="block sm:hidden">{t("add")}</div>
<div className="hidden sm:block">{t("project_cycles.add_cycle")}</div>
</Button>
</Header.RightItem>
) : (
<></>
)}
</Header>
);
});

View File

@@ -0,0 +1,23 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Outlet } from "react-router";
// components
import { AppHeader } from "@/components/core/app-header";
import { ContentWrapper } from "@/components/core/content-wrapper";
import { CyclesListHeader } from "./header";
import { CyclesListMobileHeader } from "./mobile-header";
export default function ProjectCyclesListLayout() {
return (
<>
<AppHeader header={<CyclesListHeader />} mobileHeader={<CyclesListMobileHeader />} />
<ContentWrapper>
<Outlet />
</ContentWrapper>
</>
);
}

View File

@@ -0,0 +1,80 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type React from "react";
import { observer } from "mobx-react";
// ui
import type { ISvgIcons } from "@plane/propel/icons";
import { GridOutline, ListOutline, TimelineOutline } from "@makeplane/propel/icons";
// plane package imports
import type { TCycleLayoutOptions } from "@plane/types";
import { CustomMenu } from "@plane/ui";
// hooks
import { useCycleFilter } from "@/hooks/store/use-cycle-filter";
import { useProject } from "@/hooks/store/use-project";
const CYCLE_VIEW_LAYOUTS: {
key: TCycleLayoutOptions;
icon: React.FC<ISvgIcons>;
title: string;
}[] = [
{
key: "list",
icon: ListOutline,
title: "List layout",
},
{
key: "board",
icon: GridOutline,
title: "Gallery layout",
},
{
key: "gantt",
icon: TimelineOutline,
title: "Timeline layout",
},
];
export const CyclesListMobileHeader = observer(function CyclesListMobileHeader() {
const { currentProjectDetails } = useProject();
// hooks
const { updateDisplayFilters } = useCycleFilter();
return (
<div className="flex justify-center sm:hidden">
<CustomMenu
maxHeight={"md"}
className="flex flex-grow justify-center border-b border-subtle bg-surface-1 py-2 text-13 text-secondary"
// placement="bottom-start"
customButton={
<span className="flex items-center gap-2">
<ListOutline className="h-4 w-4" />
<span className="flex flex-grow justify-center text-13 text-secondary">Layout</span>
</span>
}
customButtonClassName="flex flex-grow justify-center items-center text-secondary text-13"
closeOnSelect
>
{CYCLE_VIEW_LAYOUTS.map((layout) => {
if (layout.key == "gantt") return;
return (
<CustomMenu.MenuItem
key={layout.key}
onClick={() => {
updateDisplayFilters(currentProjectDetails!.id, {
layout: layout.key,
});
}}
className="flex items-center gap-2"
>
<layout.icon className="h-3 w-3" />
<div className="text-tertiary">{layout.title}</div>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</div>
);
});

View File

@@ -0,0 +1,140 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { useTheme } from "next-themes";
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EmptyStateDetailed } from "@plane/propel/empty-state";
import type { TCycleFilters } from "@plane/types";
import { EUserProjectRoles } from "@plane/types";
// components
import { Header, EHeaderVariant } from "@plane/ui";
import { calculateTotalFilters } from "@plane/utils";
// assets
import darkEmptyState from "@/app/assets/empty-state/disabled-feature/cycles-dark.webp?url";
import lightEmptyState from "@/app/assets/empty-state/disabled-feature/cycles-light.webp?url";
// components
import { PageHead } from "@/components/core/page-title";
import { CycleAppliedFiltersList } from "@/components/cycles/applied-filters";
import { CyclesView } from "@/components/cycles/cycles-view";
import { CycleCreateUpdateModal } from "@/components/cycles/modal";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { CycleModuleListLayoutLoader } from "@/components/ui/loader/cycle-module-list-loader";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useCycleFilter } from "@/hooks/store/use-cycle-filter";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import type { Route } from "./+types/page";
function ProjectCyclesPage({ params }: Route.ComponentProps) {
// states
const [createModal, setCreateModal] = useState(false);
// store hooks
const { currentProjectCycleIds, loader } = useCycle();
const { getProjectById, currentProjectDetails } = useProject();
// router
const router = useAppRouter();
const { workspaceSlug, projectId } = params;
// theme hook
const { resolvedTheme } = useTheme();
// plane hooks
const { t } = useTranslation();
// cycle filters hook
const { clearAllFilters, currentProjectFilters, updateFilters } = useCycleFilter();
const { allowPermissions } = useUserPermissions();
// derived values
const resolvedEmptyState = resolvedTheme === "light" ? lightEmptyState : darkEmptyState;
const totalCycles = currentProjectCycleIds?.length ?? 0;
const project = getProjectById(projectId);
const pageTitle = project?.name ? `${project?.name} - ${t("common.cycles", { count: 2 })}` : undefined;
const hasAdminLevelPermission = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT);
const hasMemberLevelPermission = allowPermissions(
[EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER],
EUserPermissionsLevel.PROJECT
);
const handleRemoveFilter = (key: keyof TCycleFilters, value: string | null) => {
let newValues = currentProjectFilters?.[key] ?? [];
if (!value) newValues = [];
else newValues = newValues.filter((val) => val !== value);
updateFilters(projectId, { [key]: newValues });
};
// No access to cycle
if (currentProjectDetails?.cycle_view === false)
return (
<div className="flex h-full w-full items-center justify-center">
<DetailedEmptyState
title={t("disabled_project.empty_state.cycle.title")}
description={t("disabled_project.empty_state.cycle.description")}
assetPath={resolvedEmptyState}
primaryButton={{
text: t("disabled_project.empty_state.cycle.primary_button.text"),
onClick: () => {
router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`);
},
disabled: !hasAdminLevelPermission,
}}
/>
</div>
);
if (loader) return <CycleModuleListLayoutLoader />;
return (
<>
<PageHead title={pageTitle} />
<div className="h-full w-full">
<CycleCreateUpdateModal
workspaceSlug={workspaceSlug}
projectId={projectId}
isOpen={createModal}
handleClose={() => setCreateModal(false)}
/>
{totalCycles === 0 ? (
<div className="h-full place-items-center">
<EmptyStateDetailed
assetKey="cycle"
title={t("project_empty_state.cycles.title")}
description={t("project_empty_state.cycles.description")}
actions={[
{
label: t("project_empty_state.cycles.cta_primary"),
onClick: () => setCreateModal(true),
variant: "primary",
disabled: !hasMemberLevelPermission,
},
]}
/>
</div>
) : (
<>
{calculateTotalFilters(currentProjectFilters ?? {}) !== 0 && (
<Header variant={EHeaderVariant.TERNARY}>
<CycleAppliedFiltersList
appliedFilters={currentProjectFilters ?? {}}
handleClearAllFilters={() => clearAllFilters(projectId)}
handleRemoveFilter={handleRemoveFilter}
/>
</Header>
)}
<CyclesView workspaceSlug={workspaceSlug} projectId={projectId} />
</>
)}
</div>
</>
);
}
export default observer(ProjectCyclesPage);

View File

@@ -0,0 +1,22 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Outlet } from "react-router";
// components
import { AppHeader } from "@/components/core/app-header";
import { ContentWrapper } from "@/components/core/content-wrapper";
import { ProjectInboxHeader } from "@/components/projects/settings/intake/header";
export default function ProjectInboxIssuesLayout() {
return (
<>
<AppHeader header={<ProjectInboxHeader />} />
<ContentWrapper>
<Outlet />
</ContentWrapper>
</>
);
}

View File

@@ -0,0 +1,95 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
import { useTheme } from "next-themes";
// plane imports
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { EUserProjectRoles, EInboxIssueCurrentTab } from "@plane/types";
// assets
import darkIntakeAsset from "@/app/assets/empty-state/disabled-feature/intake-dark.webp?url";
import lightIntakeAsset from "@/app/assets/empty-state/disabled-feature/intake-light.webp?url";
// components
import { PageHead } from "@/components/core/page-title";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { InboxIssueRoot } from "@/components/inbox";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import type { Route } from "./+types/page";
function ProjectInboxPage({ params }: Route.ComponentProps) {
/// router
const router = useAppRouter();
const { workspaceSlug, projectId } = params;
const searchParams = useSearchParams();
const navigationTab = searchParams.get("currentTab");
const inboxIssueId = searchParams.get("inboxIssueId");
// theme hook
const { resolvedTheme } = useTheme();
// plane hooks
const { t } = useTranslation();
// hooks
const { currentProjectDetails } = useProject();
const { allowPermissions } = useUserPermissions();
// derived values
const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT);
const resolvedPath = resolvedTheme === "light" ? lightIntakeAsset : darkIntakeAsset;
// No access to inbox
if (currentProjectDetails?.inbox_view === false)
return (
<div className="flex h-full w-full items-center justify-center">
<DetailedEmptyState
title={t("disabled_project.empty_state.inbox.title")}
description={t("disabled_project.empty_state.inbox.description")}
assetPath={resolvedPath}
primaryButton={{
text: t("disabled_project.empty_state.inbox.primary_button.text"),
onClick: () => {
router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`);
},
disabled: !canPerformEmptyStateActions,
}}
/>
</div>
);
// derived values
const pageTitle = currentProjectDetails?.name
? t("inbox_issue.page_label", {
workspace: currentProjectDetails?.name,
})
: t("inbox_issue.page_label", {
workspace: "Plane",
});
const currentNavigationTab = navigationTab
? navigationTab === "open"
? EInboxIssueCurrentTab.OPEN
: EInboxIssueCurrentTab.CLOSED
: undefined;
return (
<div className="flex h-full flex-col">
<PageHead title={pageTitle} />
<div className="h-full w-full overflow-hidden">
<InboxIssueRoot
workspaceSlug={workspaceSlug}
projectId={projectId}
inboxIssueId={inboxIssueId || undefined}
inboxAccessible={currentProjectDetails?.inbox_view || false}
navigationTab={currentNavigationTab}
/>
</div>
</div>
);
}
export default observer(ProjectInboxPage);

View File

@@ -0,0 +1,72 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useTheme } from "next-themes";
import { redirect } from "react-router";
import { useTranslation } from "@plane/i18n";
// assets
import emptyIssueDark from "@/app/assets/empty-state/search/issues-dark.webp?url";
import emptyIssueLight from "@/app/assets/empty-state/search/issues-light.webp?url";
// components
import { EmptyState } from "@/components/common/empty-state";
import { LogoSpinner } from "@/components/common/logo-spinner";
// hooks
import { useAppRouter } from "@/hooks/use-app-router";
// services
import { IssueService } from "@/services/issue/issue.service";
// types
import type { Route } from "./+types/page";
const issueService = new IssueService();
export async function clientLoader({ params }: Route.ClientLoaderArgs) {
const { workspaceSlug, projectId, issueId } = params;
try {
const data = await issueService.getIssueMetaFromURL(workspaceSlug, projectId, issueId);
if (data) {
throw redirect(`/${workspaceSlug}/browse/${data.project_identifier}-${data.sequence_id}`);
}
return { error: true, workspaceSlug };
} catch (error) {
// If it's a redirect, rethrow it
if (error instanceof Response) {
throw error;
}
// Otherwise return error state
return { error: true, workspaceSlug };
}
}
export default function IssueDetailsPage({ loaderData }: Route.ComponentProps) {
const router = useAppRouter();
const { t } = useTranslation();
const { resolvedTheme } = useTheme();
if (loaderData.error) {
return (
<div className="flex size-full items-center justify-center">
<EmptyState
image={resolvedTheme === "dark" ? emptyIssueDark : emptyIssueLight}
title={t("issue.empty_state.issue_detail.title")}
description={t("issue.empty_state.issue_detail.description")}
primaryButton={{
text: t("issue.empty_state.issue_detail.primary_button.text"),
onClick: () => router.push(`/${loaderData.workspaceSlug}/workspace-views/all-issues/`),
}}
/>
</div>
);
}
return (
<div className="flex size-full items-center justify-center">
<LogoSpinner />
</div>
);
}

View File

@@ -0,0 +1,11 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { IssuesHeader } from "@/components/issues/header";
export function ProjectIssuesHeader() {
return <IssuesHeader />;
}

View File

@@ -0,0 +1,23 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// components
import { Outlet } from "react-router";
import { AppHeader } from "@/components/core/app-header";
import { ContentWrapper } from "@/components/core/content-wrapper";
import { ProjectIssuesHeader } from "./header";
import { ProjectIssuesMobileHeader } from "./mobile-header";
export default function ProjectIssuesLayout() {
return (
<>
<AppHeader header={<ProjectIssuesHeader />} mobileHeader={<ProjectIssuesMobileHeader />} />
<ContentWrapper>
<Outlet />
</ContentWrapper>
</>
);
}

View File

@@ -0,0 +1,110 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { ChevronDownOutline } from "@makeplane/propel/icons";
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
// components
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
import {
DisplayFiltersSelection,
FiltersDropdown,
MobileLayoutSelection,
} from "@/components/issues/issue-layouts/filters";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
export const ProjectIssuesMobileHeader = observer(function ProjectIssuesMobileHeader() {
// i18n
const { t } = useTranslation();
const [analyticsModal, setAnalyticsModal] = useState(false);
const { workspaceSlug, projectId } = useParams();
const { currentProjectDetails } = useProject();
// store hooks
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.PROJECT);
const activeLayout = issueFilters?.displayFilters?.layout;
const handleLayoutChange = useCallback(
(layout: EIssueLayoutTypes) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout });
},
[workspaceSlug, projectId, updateFilters]
);
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter);
},
[workspaceSlug, projectId, updateFilters]
);
const handleDisplayProperties = useCallback(
(property: Partial<IIssueDisplayProperties>) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property);
},
[workspaceSlug, projectId, updateFilters]
);
return (
<>
<WorkItemsModal
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
projectDetails={currentProjectDetails ?? undefined}
/>
<div className="z-[13] flex justify-evenly border-b border-subtle bg-surface-1 py-2 md:hidden">
<MobileLayoutSelection
layouts={[EIssueLayoutTypes.LIST, EIssueLayoutTypes.KANBAN, EIssueLayoutTypes.CALENDAR]}
onChange={handleLayoutChange}
/>
<div className="flex flex-grow items-center justify-center border-l border-subtle text-13 text-secondary">
<FiltersDropdown
title={t("common.display")}
placement="bottom-end"
menuButton={
<span className="flex items-center text-13 text-secondary">
{t("common.display")}
<ChevronDownOutline className="ml-2 h-4 w-4 text-secondary" />
</span>
}
>
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
/>
</FiltersDropdown>
</div>
<button
onClick={() => setAnalyticsModal(true)}
className="flex flex-grow justify-center border-l border-subtle text-13 text-secondary"
>
{t("common.analytics")}
</button>
</div>
</>
);
});

View File

@@ -0,0 +1,38 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// i18n
import { useTranslation } from "@plane/i18n";
// components
import { PageHead } from "@/components/core/page-title";
import { ProjectLayoutRoot } from "@/components/issues/issue-layouts/roots/project-layout-root";
// hooks
import { useProject } from "@/hooks/store/use-project";
import type { Route } from "./+types/page";
function ProjectIssuesPage({ params }: Route.ComponentProps) {
const { projectId } = params;
// i18n
const { t } = useTranslation();
// store
const { getProjectById } = useProject();
// derived values
const project = getProjectById(projectId);
const pageTitle = project?.name ? `${project?.name} - ${t("issue.label", { count: 2 })}` : undefined; // Count is for pluralization
return (
<>
<PageHead title={pageTitle} />
<div className="h-full w-full">
<ProjectLayoutRoot />
</div>
</>
);
}
export default observer(ProjectIssuesPage);

View File

@@ -0,0 +1,60 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { Outlet } from "react-router";
// plane imports
import { Header, Row } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { TabNavigationRoot } from "@/components/navigation/tab-navigation-root";
import { AppSidebarToggleButton } from "@/components/sidebar/sidebar-toggle-button";
// hooks
import { useAppTheme } from "@/hooks/store/use-app-theme";
import { useProjectNavigationPreferences } from "@/hooks/use-navigation-preferences";
// layouts
import { ProjectAuthWrapper } from "@/layouts/auth-layout/project-wrapper";
// local imports
import type { Route } from "./+types/layout";
function ProjectLayout({ params }: Route.ComponentProps) {
// router
const { workspaceSlug, projectId } = params;
// store hooks
const { sidebarCollapsed } = useAppTheme();
// preferences
const { preferences: projectPreferences } = useProjectNavigationPreferences();
return (
<>
{projectPreferences.navigationMode === "TABBED" && (
<div className="z-20">
<Row className="flex h-header w-full items-center gap-2 border-b border-subtle bg-surface-1">
<div className="flex h-full w-full items-center gap-2 divide-x divide-subtle">
<div className="flex size-full flex-1 items-center gap-2">
{sidebarCollapsed && (
<div className="shrink-0">
<AppSidebarToggleButton />
</div>
)}
<Header className={cn("h-full", { "pl-1.5": !sidebarCollapsed })}>
<Header.LeftItem className="flex h-full max-w-full items-center gap-2">
<TabNavigationRoot workspaceSlug={workspaceSlug} projectId={projectId} />
</Header.LeftItem>
</Header>
</div>
</div>
</Row>
</div>
)}
<ProjectAuthWrapper workspaceSlug={workspaceSlug} projectId={projectId}>
<Outlet />
</ProjectAuthWrapper>
</>
);
}
export default observer(ProjectLayout);

View File

@@ -0,0 +1,83 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import useSWR from "swr";
// plane imports
import { cn } from "@plane/utils";
// assets
import emptyModule from "@/app/assets/empty-state/module.svg?url";
// components
import { EmptyState } from "@/components/common/empty-state";
import { PageHead } from "@/components/core/page-title";
import { ModuleLayoutRoot } from "@/components/issues/issue-layouts/roots/module-layout-root";
import { ModuleAnalyticsSidebar } from "@/components/modules";
// hooks
import { useModule } from "@/hooks/store/use-module";
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
import useLocalStorage from "@/hooks/use-local-storage";
import type { Route } from "./+types/page";
function ModuleIssuesPage({ params }: Route.ComponentProps) {
// router
const router = useAppRouter();
const { workspaceSlug, projectId, moduleId } = params;
// store hooks
const { fetchModuleDetails, getModuleById } = useModule();
const { getProjectById } = useProject();
// const { issuesFilter } = useIssues(EIssuesStoreType.MODULE);
// local storage
const { setValue, storedValue } = useLocalStorage("module_sidebar_collapsed", "false");
const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false;
// fetching module details
const { error } = useSWR(`CURRENT_MODULE_DETAILS_${moduleId}`, () =>
fetchModuleDetails(workspaceSlug, projectId, moduleId)
);
// derived values
const projectModule = getModuleById(moduleId);
const project = getProjectById(projectId);
const pageTitle = project?.name && projectModule?.name ? `${project?.name} - ${projectModule?.name}` : undefined;
const toggleSidebar = () => {
setValue(`${!isSidebarCollapsed}`);
};
// const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout;
return (
<>
<PageHead title={pageTitle} />
{error ? (
<EmptyState
image={emptyModule}
title="Module does not exist"
description="The module you are looking for does not exist or has been deleted."
primaryButton={{
text: "View other modules",
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/modules`),
}}
/>
) : (
<div className="flex h-full w-full">
<div className="h-full w-full overflow-hidden">
<ModuleLayoutRoot />
</div>
{!isSidebarCollapsed && (
<div
className={cn(
"vertical-scrollbar absolute right-0 z-13 scrollbar-sm flex h-full w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-subtle bg-surface-1 px-6 shadow-raised-200 duration-300"
)}
>
<ModuleAnalyticsSidebar moduleId={moduleId} handleClose={toggleSidebar} />
</div>
)}
</div>
)}
</>
);
}
export default observer(ModuleIssuesPage);

View File

@@ -0,0 +1,270 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// icons
import { BarOutline, ModuleOutline, PreferencesOutline, RightSidePaneOutline } from "@makeplane/propel/icons";
// plane imports
import {
EIssueFilterType,
ISSUE_DISPLAY_FILTERS_BY_PAGE,
EUserPermissions,
EUserPermissionsLevel,
} from "@plane/constants";
import { Button } from "@plane/propel/button";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import type { ICustomSearchSelectOption, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
import { EIssuesStoreType, EIssueLayoutTypes } from "@plane/types";
import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SwitcherLabel } from "@/components/common/switcher-label";
import {
DisplayFiltersSelection,
FiltersDropdown,
LayoutSelection,
MobileLayoutSelection,
} from "@/components/issues/issue-layouts/filters";
import { ModuleQuickActions } from "@/components/modules";
import { WorkItemFiltersToggle } from "@/components/work-item-filters/filters-toggle";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useIssues } from "@/hooks/store/use-issues";
import { useModule } from "@/hooks/store/use-module";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { useIssuesActions } from "@/hooks/use-issues-actions";
import useLocalStorage from "@/hooks/use-local-storage";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/components/breadcrumbs/common";
import { IconButton } from "@plane/propel/icon-button";
export const ModuleIssuesHeader = observer(function ModuleIssuesHeader() {
// refs
const parentRef = useRef<HTMLDivElement>(null);
// states
const [analyticsModal, setAnalyticsModal] = useState(false);
// router
const router = useAppRouter();
const { workspaceSlug, projectId, moduleId: routerModuleId } = useParams();
const moduleId = routerModuleId ? routerModuleId.toString() : undefined;
// hooks
const { isMobile } = usePlatformOS();
// store hooks
const {
issuesFilter: { issueFilters },
issues: { getGroupIssueCount },
} = useIssues(EIssuesStoreType.MODULE);
const { updateFilters } = useIssuesActions(EIssuesStoreType.MODULE);
const { projectModuleIds, getModuleById } = useModule();
const { toggleCreateIssueModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
const { currentProjectDetails, loader } = useProject();
// local storage
const { setValue, storedValue } = useLocalStorage("module_sidebar_collapsed", "false");
// derived values
const isSidebarCollapsed = storedValue ? storedValue === "true" : false;
const activeLayout = issueFilters?.displayFilters?.layout;
const moduleDetails = moduleId ? getModuleById(moduleId) : undefined;
const canUserCreateIssue = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
const workItemsCount = getGroupIssueCount(undefined, undefined, false);
const toggleSidebar = () => {
setValue(`${!isSidebarCollapsed}`);
};
const handleLayoutChange = useCallback(
(layout: EIssueLayoutTypes) => {
if (!projectId) return;
updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_FILTERS, { layout: layout });
},
[projectId, updateFilters]
);
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!projectId) return;
updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter);
},
[projectId, updateFilters]
);
const handleDisplayProperties = useCallback(
(property: Partial<IIssueDisplayProperties>) => {
if (!projectId) return;
updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_PROPERTIES, property);
},
[projectId, updateFilters]
);
const switcherOptions = projectModuleIds
?.map((id) => {
const _module = id === moduleId ? moduleDetails : getModuleById(id);
if (!_module) return;
return {
value: _module.id,
query: _module.name,
content: <SwitcherLabel name={_module.name} LabelIcon={ModuleOutline} />,
};
})
.filter((option) => option !== undefined) as ICustomSearchSelectOption[];
return (
<>
<WorkItemsModal
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
moduleDetails={moduleDetails ?? undefined}
projectDetails={currentProjectDetails}
/>
<Header>
<Header.LeftItem>
<div className="flex items-center gap-2">
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Modules"
href={`/${workspaceSlug}/projects/${projectId}/modules/`}
icon={<ModuleOutline className="h-4 w-4 text-tertiary" />}
isLast
/>
}
isLast
/>
<Breadcrumbs.Item
component={
<BreadcrumbNavigationSearchDropdown
selectedItem={moduleId?.toString() ?? ""}
navigationItems={switcherOptions}
onChange={(value: string) => {
router.push(`/${workspaceSlug}/projects/${projectId}/modules/${value}`);
}}
title={moduleDetails?.name}
icon={<ModuleOutline className="size-3.5 flex-shrink-0 text-tertiary" />}
isLast
/>
}
/>
</Breadcrumbs>
{workItemsCount && workItemsCount > 0 ? (
<Tooltip
label={`There are ${workItemsCount} ${workItemsCount > 1 ? "work items" : "work item"} in this module`}
layout="stacked"
side="bottom"
disabled={isMobile}
>
<span className="flex flex-shrink-0 cursor-default items-center justify-center rounded-xl bg-accent-primary/20 px-2 text-center text-11 font-semibold text-accent-primary">
{workItemsCount}
</span>
</Tooltip>
) : null}
</div>
</Header.LeftItem>
<Header.RightItem className="items-center">
<div className="hidden gap-2 md:flex">
<div className="hidden @4xl:flex">
<LayoutSelection
layouts={[
EIssueLayoutTypes.LIST,
EIssueLayoutTypes.KANBAN,
EIssueLayoutTypes.CALENDAR,
EIssueLayoutTypes.SPREADSHEET,
EIssueLayoutTypes.GANTT,
]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
</div>
<div className="flex @4xl:hidden">
<MobileLayoutSelection
layouts={[
EIssueLayoutTypes.LIST,
EIssueLayoutTypes.KANBAN,
EIssueLayoutTypes.CALENDAR,
EIssueLayoutTypes.SPREADSHEET,
EIssueLayoutTypes.GANTT,
]}
onChange={(layout) => handleLayoutChange(layout)}
activeLayout={activeLayout}
/>
</div>
{moduleId && <WorkItemFiltersToggle entityType={EIssuesStoreType.MODULE} entityId={moduleId} />}
<FiltersDropdown
title="Display"
placement="bottom-end"
miniIcon={<PreferencesOutline className="size-3.5" />}
>
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
ignoreGroupedFilters={["module"]}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
/>
</FiltersDropdown>
</div>
{canUserCreateIssue ? (
<>
<Button className="hidden md:block" onClick={() => setAnalyticsModal(true)} variant="secondary" size="lg">
<span className="hidden @4xl:flex">Analytics</span>
<span className="@4xl:hidden">
<BarOutline className="size-3.5" />
</span>
</Button>
<Button
variant="primary"
size="lg"
className="hidden sm:flex"
onClick={() => {
toggleCreateIssueModal(true, EIssuesStoreType.MODULE);
}}
>
Add work item
</Button>
</>
) : (
<></>
)}
<IconButton
variant="tertiary"
size="lg"
icon={RightSidePaneOutline}
onClick={toggleSidebar}
className={cn({
"bg-accent-subtle text-accent-primary": !isSidebarCollapsed,
})}
/>
{moduleId && (
<ModuleQuickActions
parentRef={parentRef}
moduleId={moduleId}
projectId={projectId.toString()}
workspaceSlug={workspaceSlug.toString()}
customClassName="flex-shrink-0 flex items-center justify-center bg-layer-1/70 rounded-sm size-[26px]"
/>
)}
</Header.RightItem>
</Header>
</>
);
});

View File

@@ -0,0 +1,23 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Outlet } from "react-router";
// components
import { AppHeader } from "@/components/core/app-header";
import { ContentWrapper } from "@/components/core/content-wrapper";
import { ModuleIssuesHeader } from "./header";
import { ModuleIssuesMobileHeader } from "./mobile-header";
export default function ProjectModuleIssuesLayout() {
return (
<>
<AppHeader header={<ModuleIssuesHeader />} mobileHeader={<ModuleIssuesMobileHeader />} />
<ContentWrapper>
<Outlet />
</ContentWrapper>
</>
);
}

View File

@@ -0,0 +1,138 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EIssueFilterType, ISSUE_LAYOUTS, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { BoardOutline, CalendarOutline, ChevronDownOutline, ListOutline } from "@makeplane/propel/icons";
import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, EIssueLayoutTypes } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
import { CustomMenu } from "@plane/ui";
// components
import { WorkItemsModal } from "@/components/analytics/work-items/modal";
import { DisplayFiltersSelection, FiltersDropdown } from "@/components/issues/issue-layouts/filters";
import { IssueLayoutIcon } from "@/components/issues/issue-layouts/layout-icon";
// hooks
import { useIssues } from "@/hooks/store/use-issues";
import { useModule } from "@/hooks/store/use-module";
import { useProject } from "@/hooks/store/use-project";
const SUPPORTED_LAYOUTS = [
{ key: "list", i18n_title: "issue.layouts.list", icon: ListOutline },
{ key: "kanban", i18n_title: "issue.layouts.kanban", icon: BoardOutline },
{ key: "calendar", i18n_title: "issue.layouts.calendar", icon: CalendarOutline },
];
export const ModuleIssuesMobileHeader = observer(function ModuleIssuesMobileHeader() {
// router
const { workspaceSlug, projectId, moduleId } = useParams();
// states
const [analyticsModal, setAnalyticsModal] = useState(false);
// plane hooks
const { t } = useTranslation();
// store hooks
const { currentProjectDetails } = useProject();
const { getModuleById } = useModule();
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.MODULE);
// derived values
const activeLayout = issueFilters?.displayFilters?.layout;
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
const handleLayoutChange = useCallback(
(layout: EIssueLayoutTypes) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, moduleId);
},
[workspaceSlug, projectId, moduleId, updateFilters]
);
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, moduleId);
},
[workspaceSlug, projectId, moduleId, updateFilters]
);
const handleDisplayProperties = useCallback(
(property: Partial<IIssueDisplayProperties>) => {
if (!workspaceSlug || !projectId) return;
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property, moduleId);
},
[workspaceSlug, projectId, moduleId, updateFilters]
);
return (
<div className="block md:hidden">
<WorkItemsModal
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
moduleDetails={moduleDetails ?? undefined}
projectDetails={currentProjectDetails}
/>
<div className="flex justify-evenly border-b border-subtle bg-surface-1 py-2">
<CustomMenu
maxHeight={"md"}
className="flex flex-grow justify-center text-13 text-secondary"
placement="bottom-start"
customButton={<span className="flex flex-grow justify-center text-13 text-secondary">Layout</span>}
customButtonClassName="flex flex-grow justify-center text-secondary text-13"
closeOnSelect
>
{SUPPORTED_LAYOUTS.map((layout, index) => (
<CustomMenu.MenuItem
key={layout.key}
onClick={() => {
handleLayoutChange(ISSUE_LAYOUTS[index].key);
}}
className="flex items-center gap-2"
>
<IssueLayoutIcon layout={ISSUE_LAYOUTS[index].key} className="h-3 w-3" />
<div className="text-tertiary">{t(layout.i18n_title)}</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
<div className="flex flex-grow items-center justify-center border-l border-subtle text-13 text-secondary">
<FiltersDropdown
title="Display"
placement="bottom-end"
menuButton={
<span className="flex items-center text-13 text-secondary">
Display
<ChevronDownOutline className="ml-2 h-4 w-4 text-secondary" />
</span>
}
>
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
ignoreGroupedFilters={["module"]}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
/>
</FiltersDropdown>
</div>
<button
onClick={() => setAnalyticsModal(true)}
className="flex flex-grow justify-center border-l border-subtle text-13 text-secondary"
>
Analytics
</button>
</div>
</div>
);
});

View File

@@ -0,0 +1,84 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// ui
import { Button } from "@plane/propel/button";
import { ModuleOutline } from "@makeplane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { ModuleViewHeader } from "@/components/modules";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/components/breadcrumbs/common";
export const ModulesListHeader = observer(function ModulesListHeader() {
// router
const router = useAppRouter();
const { workspaceSlug, projectId } = useParams();
// store hooks
const { toggleCreateModuleModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
const { loader } = useProject();
const { t } = useTranslation();
// auth
const canUserCreateModule = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
return (
<Header>
<Header.LeftItem>
<div>
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Modules"
href={`/${workspaceSlug}/projects/${projectId}/modules/`}
icon={<ModuleOutline className="h-4 w-4 text-tertiary" />}
isLast
/>
}
isLast
/>
</Breadcrumbs>
</div>
</Header.LeftItem>
<Header.RightItem>
<ModuleViewHeader />
{canUserCreateModule ? (
<Button
variant="primary"
onClick={() => {
toggleCreateModuleModal(true);
}}
size="lg"
>
<div className="block sm:hidden">{t("add")}</div>
<div className="hidden sm:block">{t("project_module.add_module")}</div>
</Button>
) : (
<></>
)}
</Header.RightItem>
</Header>
);
});

View File

@@ -0,0 +1,23 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Outlet } from "react-router";
// components
import { AppHeader } from "@/components/core/app-header";
import { ContentWrapper } from "@/components/core/content-wrapper";
import { ModulesListHeader } from "./header";
import { ModulesListMobileHeader } from "./mobile-header";
export default function ProjectModulesListLayout() {
return (
<>
<AppHeader header={<ModulesListHeader />} mobileHeader={<ModulesListMobileHeader />} />
<ContentWrapper>
<Outlet />
</ContentWrapper>
</>
);
}

View File

@@ -0,0 +1,53 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { MODULE_VIEW_LAYOUTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { ChevronDownOutline } from "@makeplane/propel/icons";
import { CustomMenu, Row } from "@plane/ui";
import { ModuleLayoutIcon } from "@/components/modules";
import { useModuleFilter } from "@/hooks/store/use-module-filter";
import { useProject } from "@/hooks/store/use-project";
export const ModulesListMobileHeader = observer(function ModulesListMobileHeader() {
const { currentProjectDetails } = useProject();
const { updateDisplayFilters } = useModuleFilter();
const { t } = useTranslation();
return (
<div className="flex justify-start md:hidden">
<CustomMenu
maxHeight={"md"}
className="flex flex-grow justify-start border-b border-subtle bg-surface-1 py-2 text-13 text-secondary"
// placement="bottom-start"
customButton={
<Row className="flex flex-grow justify-center gap-2 text-13 text-secondary">
<span>Layout</span> <ChevronDownOutline className="my-auto h-4 w-4 text-secondary" />
</Row>
}
customButtonClassName="flex flex-grow justify-center items-center text-secondary text-13"
closeOnSelect
>
{MODULE_VIEW_LAYOUTS.map((layout) => {
if (layout.key == "gantt") return;
return (
<CustomMenu.MenuItem
key={layout.key}
onClick={() => {
updateDisplayFilters(currentProjectDetails!.id.toString(), { layout: layout.key });
}}
className="flex items-center gap-2"
>
<ModuleLayoutIcon layoutType={layout.key} />
<div className="text-tertiary">{t(layout.i18n_title)}</div>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</div>
);
});

View File

@@ -0,0 +1,105 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback } from "react";
import { observer } from "mobx-react";
import { useTheme } from "next-themes";
// plane imports
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { TModuleFilters } from "@plane/types";
import { EUserProjectRoles } from "@plane/types";
import { calculateTotalFilters } from "@plane/utils";
// assets
import darkModulesAsset from "@/app/assets/empty-state/disabled-feature/modules-dark.webp?url";
import lightModulesAsset from "@/app/assets/empty-state/disabled-feature/modules-light.webp?url";
// components
import { PageHead } from "@/components/core/page-title";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { ModuleAppliedFiltersList, ModulesListView } from "@/components/modules";
// hooks
import { useModuleFilter } from "@/hooks/store/use-module-filter";
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import type { Route } from "./+types/page";
function ProjectModulesPage({ params }: Route.ComponentProps) {
// router
const router = useAppRouter();
const { workspaceSlug, projectId } = params;
// theme hook
const { resolvedTheme } = useTheme();
// plane hooks
const { t } = useTranslation();
// store
const { getProjectById, currentProjectDetails } = useProject();
const {
currentProjectFilters = {},
currentProjectDisplayFilters,
clearAllFilters,
updateFilters,
updateDisplayFilters,
} = useModuleFilter();
const { allowPermissions } = useUserPermissions();
// derived values
const project = getProjectById(projectId);
const pageTitle = project?.name ? `${project?.name} - Modules` : undefined;
const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT);
const resolvedPath = resolvedTheme === "light" ? lightModulesAsset : darkModulesAsset;
const handleRemoveFilter = useCallback(
(key: keyof TModuleFilters, value: string | null) => {
let newValues = currentProjectFilters[key] ?? [];
if (!value) newValues = [];
else newValues = newValues.filter((val) => val !== value);
updateFilters(projectId, { [key]: newValues });
},
[currentProjectFilters, projectId, updateFilters]
);
// No access to
if (currentProjectDetails?.module_view === false)
return (
<div className="flex h-full w-full items-center justify-center">
<DetailedEmptyState
title={t("disabled_project.empty_state.module.title")}
description={t("disabled_project.empty_state.module.description")}
assetPath={resolvedPath}
primaryButton={{
text: t("disabled_project.empty_state.module.primary_button.text"),
onClick: () => {
router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`);
},
disabled: !canPerformEmptyStateActions,
}}
/>
</div>
);
return (
<>
<PageHead title={pageTitle} />
<div className="flex h-full w-full flex-col">
{(calculateTotalFilters(currentProjectFilters) !== 0 || currentProjectDisplayFilters?.favorites) && (
<ModuleAppliedFiltersList
appliedFilters={currentProjectFilters}
isFavoriteFilterApplied={currentProjectDisplayFilters?.favorites ?? false}
handleClearAllFilters={() => clearAllFilters(projectId)}
handleRemoveFilter={handleRemoveFilter}
handleDisplayFiltersUpdate={(val) => updateDisplayFilters(projectId, val)}
alwaysAllowEditing
/>
)}
<ModulesListView />
</div>
</>
);
}
export default observer(ProjectModulesPage);

View File

@@ -0,0 +1,200 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useEffect, useMemo } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import useSWR from "swr";
// plane types
import { getButtonStyling } from "@plane/propel/button";
import type { TSearchEntityRequestPayload, TWebhookConnectionQueryParams } from "@plane/types";
import { EFileAssetType } from "@plane/types";
// plane ui
// plane utils
import { cn } from "@plane/utils";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
import { PageHead } from "@/components/core/page-title";
import { IssuePeekOverview } from "@/components/issues/peek-overview";
import type { TPageRootConfig, TPageRootHandlers } from "@/components/pages/editor/page-root";
import { PageRoot } from "@/components/pages/editor/page-root";
// hooks
import { useEditorConfig } from "@/hooks/editor";
import { useEditorAsset } from "@/hooks/store/use-editor-asset";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web hooks
import { EPageStoreType, usePage, usePageStore } from "@/hooks/store";
// plane web services
import { WorkspaceService } from "@/services/workspace.service";
// services
import { ProjectPageService, ProjectPageVersionService } from "@/services/page";
import type { Route } from "./+types/page";
const workspaceService = new WorkspaceService();
const projectPageService = new ProjectPageService();
const projectPageVersionService = new ProjectPageVersionService();
const storeType = EPageStoreType.PROJECT;
function PageDetailsPage({ params }: Route.ComponentProps) {
// router
const router = useAppRouter();
const { workspaceSlug, projectId, pageId } = params;
// store hooks
const { createPage, fetchPageDetails } = usePageStore(storeType);
const page = usePage({
pageId,
storeType,
});
const { getWorkspaceBySlug } = useWorkspace();
const { uploadEditorAsset, duplicateEditorAsset } = useEditorAsset();
// derived values
const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug)?.id ?? "") : "";
const { canCurrentUserAccessPage, id, name, updateDescription } = page ?? {};
// entity search handler
const fetchEntityCallback = useCallback(
async (payload: TSearchEntityRequestPayload) =>
await workspaceService.searchEntity(workspaceSlug, {
...payload,
project_id: projectId,
}),
[projectId, workspaceSlug]
);
// editor config
const { getEditorFileHandlers } = useEditorConfig();
// fetch page details
const { error: pageDetailsError } = useSWR(
`PAGE_DETAILS_${pageId}`,
() => fetchPageDetails(workspaceSlug, projectId, pageId),
{
revalidateIfStale: true,
revalidateOnFocus: true,
revalidateOnReconnect: true,
}
);
// page root handlers
const pageRootHandlers: TPageRootHandlers = useMemo(
() => ({
create: createPage,
fetchAllVersions: async (pageId) =>
await projectPageVersionService.fetchAllVersions(workspaceSlug, projectId, pageId),
fetchDescriptionBinary: async () => {
if (!id) return;
return await projectPageService.fetchDescriptionBinary(workspaceSlug, projectId, id);
},
fetchEntity: fetchEntityCallback,
fetchVersionDetails: async (pageId, versionId) =>
await projectPageVersionService.fetchVersionById(workspaceSlug, projectId, pageId, versionId),
restoreVersion: async (pageId, versionId) =>
await projectPageVersionService.restoreVersion(workspaceSlug, projectId, pageId, versionId),
getRedirectionLink: (pageId) => {
if (pageId) {
return `/${workspaceSlug}/projects/${projectId}/pages/${pageId}`;
} else {
return `/${workspaceSlug}/projects/${projectId}/pages`;
}
},
updateDescription: updateDescription ?? (async () => {}),
}),
[createPage, fetchEntityCallback, id, updateDescription, workspaceSlug, projectId]
);
// page root config
const pageRootConfig: TPageRootConfig = useMemo(
() => ({
fileHandler: getEditorFileHandlers({
projectId,
uploadFile: async (blockId, file) => {
const { asset_id } = await uploadEditorAsset({
blockId,
data: {
entity_identifier: id ?? "",
entity_type: EFileAssetType.PAGE_DESCRIPTION,
},
file,
projectId,
workspaceSlug,
});
return asset_id;
},
duplicateFile: async (assetId: string) => {
const { asset_id } = await duplicateEditorAsset({
assetId,
entityId: id,
entityType: EFileAssetType.PAGE_DESCRIPTION,
projectId,
workspaceSlug,
});
return asset_id;
},
workspaceId,
workspaceSlug,
}),
}),
[getEditorFileHandlers, projectId, workspaceId, workspaceSlug, uploadEditorAsset, id, duplicateEditorAsset]
);
const webhookConnectionParams: TWebhookConnectionQueryParams = useMemo(
() => ({
documentType: "project_page",
projectId,
workspaceSlug,
}),
[projectId, workspaceSlug]
);
useEffect(() => {
if (page?.deleted_at && page?.id) {
router.push(pageRootHandlers.getRedirectionLink());
}
}, [page?.deleted_at, page?.id, router, pageRootHandlers]);
if ((!page || !id) && !pageDetailsError)
return (
<div className="grid size-full place-items-center">
<LogoSpinner />
</div>
);
if (pageDetailsError || !canCurrentUserAccessPage)
return (
<div className="flex h-full w-full flex-col items-center justify-center">
<h3 className="text-center text-16 font-semibold">Page not found</h3>
<p className="mt-3 text-center text-13 text-secondary">
The page you are trying to access doesn{"'"}t exist or you don{"'"}t have permission to view it.
</p>
<Link
href={`/${workspaceSlug}/projects/${projectId}/pages`}
className={cn(getButtonStyling("secondary", "base"), "mt-5")}
>
View other Pages
</Link>
</div>
);
if (!page) return null;
return (
<>
<PageHead title={name} />
<div className="flex h-full flex-col justify-between">
<div className="relative flex h-full w-full flex-shrink-0 flex-col overflow-hidden">
<PageRoot
config={pageRootConfig}
handlers={pageRootHandlers}
storeType={storeType}
page={page}
webhookConnectionParams={webhookConnectionParams}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<IssuePeekOverview />
</div>
</div>
</>
);
}
export default observer(PageDetailsPage);

View File

@@ -0,0 +1,108 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import { PagesOutline } from "@makeplane/propel/icons";
import type { ICustomSearchSelectOption } from "@plane/types";
import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
import { getPageName } from "@plane/utils";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { PageAccessIcon } from "@/components/common/page-access-icon";
import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label";
import { PageHeaderActions } from "@/components/pages/header/actions";
import { PageSyncingBadge } from "@/components/pages/header/syncing-badge";
import { CommonProjectBreadcrumbs } from "@/components/breadcrumbs/common";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useAppRouter } from "@/hooks/use-app-router";
import { EPageStoreType, usePage, usePageStore } from "@/hooks/store";
export interface IPagesHeaderProps {
showButton?: boolean;
}
const storeType = EPageStoreType.PROJECT;
export const PageDetailsHeader = observer(function PageDetailsHeader() {
// router
const router = useAppRouter();
const { workspaceSlug, pageId, projectId } = useParams();
// store hooks
const { loader } = useProject();
const { getPageById, getCurrentProjectPageIds } = usePageStore(storeType);
const page = usePage({
pageId: pageId?.toString() ?? "",
storeType,
});
// derived values
const projectPageIds = getCurrentProjectPageIds(projectId?.toString());
const switcherOptions = projectPageIds
.map((id) => {
const _page = id === pageId ? page : getPageById(id);
if (!_page) return;
return {
value: _page.id,
query: _page.name,
content: (
<div className="flex items-center justify-between gap-2">
<SwitcherLabel logo_props={_page.logo_props} name={getPageName(_page.name)} LabelIcon={PagesOutline} />
<PageAccessIcon {..._page} />
</div>
),
};
})
.filter((option) => option !== undefined) as ICustomSearchSelectOption[];
if (!page) return null;
return (
<Header>
<Header.LeftItem>
<div>
<Breadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Pages"
href={`/${workspaceSlug}/projects/${projectId}/pages/`}
icon={<PagesOutline className="h-4 w-4 text-tertiary" />}
/>
}
/>
<Breadcrumbs.Item
component={
<BreadcrumbNavigationSearchDropdown
selectedItem={pageId?.toString() ?? ""}
navigationItems={switcherOptions}
onChange={(value: string) => {
router.push(`/${workspaceSlug}/projects/${projectId}/pages/${value}`);
}}
title={getPageName(page?.name)}
icon={
<Breadcrumbs.Icon>
<SwitcherIcon logo_props={page.logo_props} LabelIcon={PagesOutline} size={16} />
</Breadcrumbs.Icon>
}
isLast
/>
}
/>
</Breadcrumbs>
</div>
</Header.LeftItem>
<Header.RightItem>
<PageSyncingBadge syncStatus={page.isSyncingWithServer} />
<PageHeaderActions page={page} storeType={storeType} />
</Header.RightItem>
</Header>
);
});

View File

@@ -0,0 +1,31 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// component
import { Outlet } from "react-router";
import useSWR from "swr";
import { AppHeader } from "@/components/core/app-header";
import { ContentWrapper } from "@/components/core/content-wrapper";
// plane web hooks
import { EPageStoreType, usePageStore } from "@/hooks/store";
// local components
import type { Route } from "./+types/layout";
import { PageDetailsHeader } from "./header";
export default function ProjectPageDetailsLayout({ params }: Route.ComponentProps) {
const { workspaceSlug, projectId } = params;
const { fetchPagesList } = usePageStore(EPageStoreType.PROJECT);
// fetching pages list
useSWR(`PROJECT_PAGES_${projectId}`, () => fetchPagesList(workspaceSlug, projectId));
return (
<>
<AppHeader header={<PageDetailsHeader />} />
<ContentWrapper>
<Outlet />
</ContentWrapper>
</>
);
}

View File

@@ -0,0 +1,89 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
// constants
import { EPageAccess } from "@plane/constants";
// plane types
import { Button } from "@plane/propel/button";
import { PagesOutline } from "@makeplane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TPage } from "@plane/types";
// plane ui
import { Breadcrumbs, Header } from "@plane/ui";
// helpers
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
// hooks
import { useProject } from "@/hooks/store/use-project";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/components/breadcrumbs/common";
import { EPageStoreType, usePageStore } from "@/hooks/store";
export const PagesListHeader = observer(function PagesListHeader() {
// states
const [isCreatingPage, setIsCreatingPage] = useState(false);
// router
const router = useRouter();
const { workspaceSlug, projectId } = useParams();
const searchParams = useSearchParams();
const pageType = searchParams.get("type");
// store hooks
const { currentProjectDetails, loader } = useProject();
const { canCurrentUserCreatePage, createPage } = usePageStore(EPageStoreType.PROJECT);
// handle page create
const handleCreatePage = async () => {
setIsCreatingPage(true);
const payload: Partial<TPage> = {
access: pageType === "private" ? EPageAccess.PRIVATE : EPageAccess.PUBLIC,
};
await createPage(payload)
// oxlint-disable-next-line promise/always-return
.then((res) => {
const pageId = `/${workspaceSlug}/projects/${currentProjectDetails?.id}/pages/${res?.id}`;
router.push(pageId);
})
.catch((err) => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: err?.data?.error || "Page could not be created. Please try again.",
});
})
.finally(() => setIsCreatingPage(false));
};
return (
<Header>
<Header.LeftItem>
<Breadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Pages"
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/pages/`}
icon={<PagesOutline className="h-4 w-4 text-tertiary" />}
isLast
/>
}
isLast
/>
</Breadcrumbs>
</Header.LeftItem>
{canCurrentUserCreatePage && (
<Header.RightItem>
<Button variant="primary" size="lg" onClick={handleCreatePage} loading={isCreatingPage}>
{isCreatingPage ? "Adding" : "Add page"}
</Button>
</Header.RightItem>
)}
</Header>
);
});

View File

@@ -0,0 +1,23 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// components
import { Outlet } from "react-router";
import { AppHeader } from "@/components/core/app-header";
import { ContentWrapper } from "@/components/core/content-wrapper";
// local components
import { PagesListHeader } from "./header";
export default function ProjectPagesListLayout() {
return (
<>
<AppHeader header={<PagesListHeader />} />
<ContentWrapper>
<Outlet />
</ContentWrapper>
</>
);
}

View File

@@ -0,0 +1,90 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
import { useTheme } from "next-themes";
// plane imports
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { TPageNavigationTabs } from "@plane/types";
import { EUserProjectRoles } from "@plane/types";
// assets
import darkPagesAsset from "@/app/assets/empty-state/disabled-feature/pages-dark.webp?url";
import lightPagesAsset from "@/app/assets/empty-state/disabled-feature/pages-light.webp?url";
// components
import { PageHead } from "@/components/core/page-title";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { PagesListRoot } from "@/components/pages/list/root";
import { PagesListView } from "@/components/pages/pages-list-view";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web hooks
import { EPageStoreType } from "@/hooks/store";
import type { Route } from "./+types/page";
const getPageType = (pageType?: string | null): TPageNavigationTabs => {
if (pageType === "private") return "private";
if (pageType === "archived") return "archived";
return "public";
};
function ProjectPagesPage({ params }: Route.ComponentProps) {
// router
const router = useAppRouter();
const searchParams = useSearchParams();
const type = searchParams.get("type");
const { workspaceSlug, projectId } = params;
// theme hook
const { resolvedTheme } = useTheme();
// plane hooks
const { t } = useTranslation();
// store hooks
const { getProjectById, currentProjectDetails } = useProject();
const { allowPermissions } = useUserPermissions();
// derived values
const project = getProjectById(projectId);
const pageTitle = project?.name ? `${project?.name} - Pages` : undefined;
const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT);
const resolvedPath = resolvedTheme === "light" ? lightPagesAsset : darkPagesAsset;
const pageType = getPageType(type);
// No access to cycle
if (currentProjectDetails?.page_view === false)
return (
<div className="flex h-full w-full items-center justify-center">
<DetailedEmptyState
title={t("disabled_project.empty_state.page.title")}
description={t("disabled_project.empty_state.page.description")}
assetPath={resolvedPath}
primaryButton={{
text: t("disabled_project.empty_state.page.primary_button.text"),
onClick: () => {
router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`);
},
disabled: !canPerformEmptyStateActions,
}}
/>
</div>
);
return (
<>
<PageHead title={pageTitle} />
<PagesListView
pageType={pageType}
projectId={projectId}
storeType={EPageStoreType.PROJECT}
workspaceSlug={workspaceSlug}
>
<PagesListRoot pageType={pageType} storeType={EPageStoreType.PROJECT} />
</PagesListView>
</>
);
}
export default observer(ProjectPagesPage);

View File

@@ -0,0 +1,221 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useRef } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane imports
import {
EIssueFilterType,
ISSUE_DISPLAY_FILTERS_BY_PAGE,
EUserPermissions,
EUserPermissionsLevel,
} from "@plane/constants";
import { Button } from "@plane/propel/button";
import { LockOutline, ViewsOutline } from "@makeplane/propel/icons";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import type { ICustomSearchSelectOption, IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
import { EIssuesStoreType, EViewAccess, EIssueLayoutTypes } from "@plane/types";
import { Breadcrumbs, Header, BreadcrumbNavigationSearchDropdown } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SwitcherIcon, SwitcherLabel } from "@/components/common/switcher-label";
import { DisplayFiltersSelection, FiltersDropdown, LayoutSelection } from "@/components/issues/issue-layouts/filters";
import { ViewQuickActions } from "@/components/views/quick-actions";
import { WorkItemFiltersToggle } from "@/components/work-item-filters/filters-toggle";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useIssues } from "@/hooks/store/use-issues";
import { useProject } from "@/hooks/store/use-project";
import { useProjectView } from "@/hooks/store/use-project-view";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/components/breadcrumbs/common";
export const ProjectViewIssuesHeader = observer(function ProjectViewIssuesHeader() {
// refs
const parentRef = useRef(null);
// router
const router = useAppRouter();
const { workspaceSlug, projectId, viewId: routerViewId } = useParams();
const viewId = routerViewId ? routerViewId.toString() : undefined;
// store hooks
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.PROJECT_VIEW);
const { toggleCreateIssueModal } = useCommandPalette();
const { allowPermissions } = useUserPermissions();
const { currentProjectDetails, loader } = useProject();
const { projectViewIds, getViewById } = useProjectView();
const activeLayout = issueFilters?.displayFilters?.layout;
const handleLayoutChange = useCallback(
(layout: EIssueLayoutTypes) => {
if (!workspaceSlug || !projectId || !viewId) return;
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.DISPLAY_FILTERS,
{ layout: layout },
viewId.toString()
);
},
[workspaceSlug, projectId, viewId, updateFilters]
);
const handleDisplayFilters = useCallback(
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
if (!workspaceSlug || !projectId || !viewId) return;
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.DISPLAY_FILTERS,
updatedDisplayFilter,
viewId.toString()
);
},
[workspaceSlug, projectId, viewId, updateFilters]
);
const handleDisplayProperties = useCallback(
(property: Partial<IIssueDisplayProperties>) => {
if (!workspaceSlug || !projectId || !viewId) return;
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.DISPLAY_PROPERTIES,
property,
viewId.toString()
);
},
[workspaceSlug, projectId, viewId, updateFilters]
);
const viewDetails = viewId ? getViewById(viewId.toString()) : null;
const canUserCreateIssue = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
if (!viewDetails) return;
const switcherOptions = projectViewIds
?.map((id) => {
const _view = id === viewId ? viewDetails : getViewById(id);
if (!_view) return;
return {
value: _view.id,
query: _view.name,
content: <SwitcherLabel logo_props={_view.logo_props} name={_view.name} LabelIcon={ViewsOutline} />,
};
})
.filter((option) => option !== undefined) as ICustomSearchSelectOption[];
return (
<Header>
<Header.LeftItem>
<Breadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Views"
href={`/${workspaceSlug}/projects/${projectId}/views/`}
icon={<ViewsOutline className="h-4 w-4 text-tertiary" />}
/>
}
/>
<Breadcrumbs.Item
component={
<BreadcrumbNavigationSearchDropdown
selectedItem={viewId?.toString() ?? ""}
navigationItems={switcherOptions}
onChange={(value: string) => {
router.push(`/${workspaceSlug}/projects/${projectId}/views/${value}`);
}}
title={viewDetails?.name}
icon={
<Breadcrumbs.Icon>
<SwitcherIcon logo_props={viewDetails.logo_props} LabelIcon={ViewsOutline} size={16} />
</Breadcrumbs.Icon>
}
isLast
/>
}
/>
</Breadcrumbs>
{viewDetails?.access === EViewAccess.PRIVATE ? (
<div className="cursor-default text-tertiary">
<Tooltip label={"Private"}>
<LockOutline className="h-4 w-4" />
</Tooltip>
</div>
) : (
<></>
)}
</Header.LeftItem>
<Header.RightItem className="items-center">
<>
{!viewDetails.is_locked && (
<LayoutSelection
layouts={[
EIssueLayoutTypes.LIST,
EIssueLayoutTypes.KANBAN,
EIssueLayoutTypes.CALENDAR,
EIssueLayoutTypes.SPREADSHEET,
EIssueLayoutTypes.GANTT,
]}
onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout}
/>
)}
{viewId && <WorkItemFiltersToggle entityType={EIssuesStoreType.PROJECT_VIEW} entityId={viewId} />}
{!viewDetails.is_locked && (
<FiltersDropdown title="Display" placement="bottom-end">
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues.layoutOptions[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
/>
</FiltersDropdown>
)}
</>
{canUserCreateIssue && (
<Button
variant="primary"
size="lg"
onClick={() => {
toggleCreateIssueModal(true, EIssuesStoreType.PROJECT_VIEW);
}}
>
Add work item
</Button>
)}
<div className="hidden md:block">
<ViewQuickActions
parentRef={parentRef}
customClassName="flex-shrink-0 flex items-center justify-center size-[26px] bg-layer-1/70 rounded-sm"
projectId={projectId.toString()}
view={viewDetails}
workspaceSlug={workspaceSlug.toString()}
/>
</div>
</Header.RightItem>
</Header>
);
});

View File

@@ -0,0 +1,57 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import useSWR from "swr";
// assets
import emptyView from "@/app/assets/empty-state/view.svg?url";
// components
import { EmptyState } from "@/components/common/empty-state";
import { PageHead } from "@/components/core/page-title";
import { ProjectViewLayoutRoot } from "@/components/issues/issue-layouts/roots/project-view-layout-root";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useProjectView } from "@/hooks/store/use-project-view";
import { useAppRouter } from "@/hooks/use-app-router";
import type { Route } from "./+types/page";
function ProjectViewIssuesPage({ params }: Route.ComponentProps) {
// router
const router = useAppRouter();
const { workspaceSlug, projectId, viewId } = params;
// store hooks
const { fetchViewDetails, getViewById } = useProjectView();
const { getProjectById } = useProject();
// derived values
const projectView = getViewById(viewId);
const project = getProjectById(projectId);
const pageTitle = project?.name && projectView?.name ? `${project?.name} - ${projectView?.name}` : undefined;
const { error } = useSWR(`VIEW_DETAILS_${viewId}`, () => fetchViewDetails(workspaceSlug, projectId, viewId));
if (error) {
return (
<EmptyState
image={emptyView}
title="View does not exist"
description="The view you are looking for does not exist or you don't have permission to view it."
primaryButton={{
text: "View other views",
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/views`),
}}
/>
);
}
return (
<>
<PageHead title={pageTitle} />
<ProjectViewLayoutRoot />
</>
);
}
export default observer(ProjectViewIssuesPage);

View File

@@ -0,0 +1,22 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Outlet } from "react-router";
import { AppHeader } from "@/components/core/app-header";
import { ContentWrapper } from "@/components/core/content-wrapper";
// local components
import { ProjectViewIssuesHeader } from "./[viewId]/header";
export default function ProjectViewIssuesLayout() {
return (
<>
<AppHeader header={<ProjectViewIssuesHeader />} />
<ContentWrapper>
<Outlet />
</ContentWrapper>
</>
);
}

View File

@@ -0,0 +1,58 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// ui
import { Button } from "@plane/propel/button";
import { ViewsOutline } from "@makeplane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { ViewListHeader } from "@/components/views/view-list-header";
// hooks
import { useCommandPalette } from "@/hooks/store/use-command-palette";
import { useProject } from "@/hooks/store/use-project";
// plane web imports
import { CommonProjectBreadcrumbs } from "@/components/breadcrumbs/common";
export const ProjectViewsHeader = observer(function ProjectViewsHeader() {
const { workspaceSlug, projectId } = useParams();
// store hooks
const { toggleCreateViewModal } = useCommandPalette();
const { loader } = useProject();
return (
<>
<Header>
<Header.LeftItem>
<Breadcrumbs isLoading={loader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item
component={
<BreadcrumbLink
label="Views"
href={`/${workspaceSlug}/projects/${projectId}/views/`}
icon={<ViewsOutline className="h-4 w-4 text-tertiary" />}
isLast
/>
}
isLast
/>
</Breadcrumbs>
</Header.LeftItem>
<Header.RightItem>
<ViewListHeader />
<div>
<Button variant="primary" size="lg" onClick={() => toggleCreateViewModal(true)}>
Add view
</Button>
</div>
</Header.RightItem>
</Header>
</>
);
});

View File

@@ -0,0 +1,23 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Outlet } from "react-router";
import { AppHeader } from "@/components/core/app-header";
import { ContentWrapper } from "@/components/core/content-wrapper";
// local components
import { ProjectViewsHeader } from "./header";
import { ViewMobileHeader } from "./mobile-header";
export default function ProjectViewsListLayout() {
return (
<>
<AppHeader header={<ProjectViewsHeader />} mobileHeader={<ViewMobileHeader />} />
<ContentWrapper>
<Outlet />
</ContentWrapper>
</>
);
}

View File

@@ -0,0 +1,63 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// icons
import { ChevronDownOutline, FilterOutline } from "@makeplane/propel/icons";
// components
import { Row } from "@plane/ui";
import { FiltersDropdown } from "@/components/issues/issue-layouts/filters";
import { ViewFiltersSelection } from "@/components/views/filters/filter-selection";
import { ViewOrderByDropdown } from "@/components/views/filters/order-by";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useProjectView } from "@/hooks/store/use-project-view";
export const ViewMobileHeader = observer(function ViewMobileHeader() {
// store hooks
const { filters, updateFilters } = useProjectView();
const {
project: { projectMemberIds },
} = useMember();
return (
<>
<div className="z-[13] flex justify-evenly border-b border-subtle bg-surface-1 py-2 md:hidden">
<Row className="flex flex-grow items-center justify-center border-l border-subtle text-13 text-secondary">
<ViewOrderByDropdown
sortBy={filters.sortBy}
sortKey={filters.sortKey}
onChange={(val) => {
if (val.key) updateFilters("sortKey", val.key);
if (val.order) updateFilters("sortBy", val.order);
}}
isMobile
/>
</Row>
<div className="flex flex-grow items-center justify-center border-l border-subtle text-13 text-secondary">
<FiltersDropdown
icon={<FilterOutline className="h-3 w-3" />}
title="Filters"
placement="bottom-end"
isFiltersApplied={false}
menuButton={
<Row className="flex items-center text-13 text-secondary">
Filters
<ChevronDownOutline className="ml-2 h-4 w-4 text-secondary" />
</Row>
}
>
<ViewFiltersSelection
filters={filters}
handleFiltersUpdate={updateFilters}
memberIds={projectMemberIds ?? undefined}
/>
</FiltersDropdown>
</div>
</div>
</>
);
});

View File

@@ -0,0 +1,106 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback } from "react";
import { observer } from "mobx-react";
import { useTheme } from "next-themes";
// plane imports
import { EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { EViewAccess, TViewFilterProps } from "@plane/types";
import { EUserProjectRoles } from "@plane/types";
import { Header, EHeaderVariant } from "@plane/ui";
import { calculateTotalFilters } from "@plane/utils";
// assets
import darkViewsAsset from "@/app/assets/empty-state/disabled-feature/views-dark.webp?url";
import lightViewsAsset from "@/app/assets/empty-state/disabled-feature/views-light.webp?url";
// components
import { PageHead } from "@/components/core/page-title";
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
import { ViewAppliedFiltersList } from "@/components/views/applied-filters";
import { ProjectViewsList } from "@/components/views/views-list";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useProjectView } from "@/hooks/store/use-project-view";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import type { Route } from "./+types/page";
function ProjectViewsPage({ params }: Route.ComponentProps) {
// router
const router = useAppRouter();
const { workspaceSlug, projectId } = params;
// theme hook
const { resolvedTheme } = useTheme();
// plane hooks
const { t } = useTranslation();
// store
const { getProjectById, currentProjectDetails } = useProject();
const { filters, updateFilters, clearAllFilters } = useProjectView();
const { allowPermissions } = useUserPermissions();
// derived values
const project = getProjectById(projectId);
const pageTitle = project?.name ? `${project?.name} - Views` : undefined;
const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT);
const resolvedPath = resolvedTheme === "light" ? lightViewsAsset : darkViewsAsset;
const handleRemoveFilter = useCallback(
(key: keyof TViewFilterProps, value: string | EViewAccess | null) => {
let newValues = filters.filters?.[key];
if (key === "favorites") {
newValues = !!value;
}
if (Array.isArray(newValues)) {
if (!value) newValues = [];
else newValues = newValues.filter((val) => val !== value) as string[];
}
updateFilters("filters", { [key]: newValues });
},
[filters.filters, updateFilters]
);
const isFiltersApplied = calculateTotalFilters(filters?.filters ?? {}) !== 0;
// No access to
if (currentProjectDetails?.issue_views_view === false)
return (
<div className="flex h-full w-full items-center justify-center">
<DetailedEmptyState
title={t("disabled_project.empty_state.view.title")}
description={t("disabled_project.empty_state.view.description")}
assetPath={resolvedPath}
primaryButton={{
text: t("disabled_project.empty_state.view.primary_button.text"),
onClick: () => {
router.push(`/${workspaceSlug}/settings/projects/${projectId}/features`);
},
disabled: !canPerformEmptyStateActions,
}}
/>
</div>
);
return (
<>
<PageHead title={pageTitle} />
{isFiltersApplied && (
<Header variant={EHeaderVariant.TERNARY}>
<ViewAppliedFiltersList
appliedFilters={filters.filters ?? {}}
handleClearAllFilters={clearAllFilters}
handleRemoveFilter={handleRemoveFilter}
alwaysAllowEditing
/>
</Header>
)}
<ProjectViewsList />
</>
);
}
export default observer(ProjectViewsPage);

View File

@@ -0,0 +1,23 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// components
import { Outlet } from "react-router";
import { AppHeader } from "@/components/core/app-header";
import { ContentWrapper } from "@/components/core/content-wrapper";
import { ProjectsListMobileHeader } from "@/components/projects/mobile-header";
import { ProjectsBaseHeader } from "@/components/project/header";
export default function ProjectListLayout() {
return (
<>
<AppHeader header={<ProjectsBaseHeader />} mobileHeader={<ProjectsListMobileHeader />} />
<ContentWrapper>
<Outlet />
</ContentWrapper>
</>
);
}

View File

@@ -0,0 +1,13 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { ProjectPageRoot } from "@/components/projects/page";
function ProjectsPage() {
return <ProjectPageRoot />;
}
export default ProjectsPage;