[WEB-4098] feat: noindex/nofollow (#7088)
* feat: noindex/nofollow - On login: nofollow - On app pages: noindex, nofollow https://app.plane.so/plane/browse/WEB-4098/ - https://nextjs.org/docs/app/api-reference/file-conventions/layout - https://nextjs.org/docs/app/building-your-application/routing/route-groups#creating-multiple-root-layouts - https://nextjs.org/docs/app/api-reference/functions/generate-metadata#link-relpreload * chore: address PR feedback
This commit is contained in:
parent
a3b9152a9b
commit
f8ca1e46b1
142 changed files with 92 additions and 16 deletions
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { Breadcrumbs, ContrastIcon, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
// plane web components
|
||||
import { UpgradeBadge } from "@/plane-web/components/workspace";
|
||||
|
||||
export const WorkspaceActiveCycleHeader = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={t("active_cycles")}
|
||||
icon={<ContrastIcon className="h-4 w-4 text-custom-text-300 rotate-180" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<UpgradeBadge size="md" />
|
||||
</Header.LeftItem>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
import { WorkspaceActiveCycleHeader } from "./header";
|
||||
|
||||
export default function WorkspaceActiveCycleLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<WorkspaceActiveCycleHeader />} />
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
// plane web components
|
||||
import { WorkspaceActiveCyclesRoot } from "@/plane-web/components/active-cycles";
|
||||
|
||||
const WorkspaceActiveCyclesPage = observer(() => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
// derived values
|
||||
const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Active Cycles` : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<WorkspaceActiveCyclesRoot />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default WorkspaceActiveCyclesPage;
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { BarChart2, PanelRight } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store";
|
||||
export const WorkspaceAnalyticsHeader = observer(() => {
|
||||
const { t } = useTranslation();
|
||||
const searchParams = useSearchParams();
|
||||
const analytics_tab = searchParams.get("analytics_tab");
|
||||
// store hooks
|
||||
const { workspaceAnalyticsSidebarCollapsed, toggleWorkspaceAnalyticsSidebar } = useAppTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const handleToggleWorkspaceAnalyticsSidebar = () => {
|
||||
if (window && window.innerWidth < 768) {
|
||||
toggleWorkspaceAnalyticsSidebar(true);
|
||||
}
|
||||
if (window && workspaceAnalyticsSidebarCollapsed && window.innerWidth >= 768) {
|
||||
toggleWorkspaceAnalyticsSidebar(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleToggleWorkspaceAnalyticsSidebar);
|
||||
handleToggleWorkspaceAnalyticsSidebar();
|
||||
return () => window.removeEventListener("resize", handleToggleWorkspaceAnalyticsSidebar);
|
||||
}, [toggleWorkspaceAnalyticsSidebar, workspaceAnalyticsSidebarCollapsed]);
|
||||
|
||||
return (
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={t("workspace_analytics.label")}
|
||||
icon={<BarChart2 className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
{analytics_tab === "custom" ? (
|
||||
<button
|
||||
className="block md:hidden"
|
||||
onClick={() => {
|
||||
toggleWorkspaceAnalyticsSidebar();
|
||||
}}
|
||||
>
|
||||
<PanelRight
|
||||
className={cn(
|
||||
"block h-4 w-4 md:hidden",
|
||||
!workspaceAnalyticsSidebarCollapsed ? "text-custom-primary-100" : "text-custom-text-200"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Header.LeftItem>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
// components
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
// plane web components
|
||||
import { WorkspaceAnalyticsHeader } from "./header";
|
||||
|
||||
export default function WorkspaceAnalyticsLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<WorkspaceAnalyticsHeader />} />
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
101
web/app/(all)/[workspaceSlug]/(projects)/analytics/page.tsx
Normal file
101
web/app/(all)/[workspaceSlug]/(projects)/analytics/page.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
// plane package imports
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Tabs } from "@plane/ui";
|
||||
// components
|
||||
import AnalyticsFilterActions from "@/components/analytics-v2/analytics-filter-actions";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ComicBoxButton, DetailedEmptyState } from "@/components/empty-state";
|
||||
// hooks
|
||||
import { useCommandPalette, useEventTracker, useProject, useUserPermissions, useWorkspace } from "@/hooks/store";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
import { ANALYTICS_TABS } from "@/plane-web/components/analytics-v2/tabs";
|
||||
|
||||
const AnalyticsPage = observer(() => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
// plane imports
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { toggleCreateProjectModal } = useCommandPalette();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { workspaceProjectIds, loader } = useProject();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// helper hooks
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/onboarding/analytics" });
|
||||
// derived values
|
||||
const pageTitle = currentWorkspace?.name
|
||||
? t(`workspace_analytics.page_label`, { workspace: currentWorkspace?.name })
|
||||
: undefined;
|
||||
|
||||
// permissions
|
||||
const canPerformEmptyStateActions = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
const tabs = useMemo(
|
||||
() =>
|
||||
ANALYTICS_TABS.map((tab) => ({
|
||||
key: tab.key,
|
||||
label: t(tab.i18nKey),
|
||||
content: <tab.content />,
|
||||
onClick: () => {
|
||||
router.push(`?tab=${tab.key}`);
|
||||
},
|
||||
})),
|
||||
[router, t]
|
||||
);
|
||||
const defaultTab = searchParams.get("tab") || ANALYTICS_TABS[0].key;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
{workspaceProjectIds && (
|
||||
<>
|
||||
{workspaceProjectIds.length > 0 || loader === "init-loader" ? (
|
||||
<div className="flex h-full overflow-hidden bg-custom-background-100 justify-between items-center ">
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
storageKey={`analytics-page-${currentWorkspace?.id}`}
|
||||
defaultTab={defaultTab}
|
||||
size="md"
|
||||
tabListContainerClassName="px-6 py-2 border-b border-custom-border-200 flex items-center justify-between"
|
||||
tabListClassName="my-2 max-w-36"
|
||||
tabPanelClassName="h-full w-full overflow-hidden overflow-y-auto"
|
||||
storeInLocalStorage={false}
|
||||
actions={<AnalyticsFilterActions />}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DetailedEmptyState
|
||||
title={t("workspace_analytics.empty_state.general.title")}
|
||||
description={t("workspace_analytics.empty_state.general.description")}
|
||||
assetPath={resolvedPath}
|
||||
customPrimaryButton={
|
||||
<ComicBoxButton
|
||||
label={t("workspace_analytics.empty_state.general.primary_button.text")}
|
||||
title={t("workspace_analytics.empty_state.general.primary_button.comic.title")}
|
||||
description={t("workspace_analytics.empty_state.general.primary_button.comic.description")}
|
||||
onClick={() => {
|
||||
setTrackElement("Analytics empty state");
|
||||
toggleCreateProjectModal(true);
|
||||
}}
|
||||
disabled={!canPerformEmptyStateActions}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default AnalyticsPage;
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Briefcase } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs, LayersIcon, Header, Logo } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
import { IssueDetailQuickActions } from "@/components/issues";
|
||||
// hooks
|
||||
import { useIssueDetail, useProject } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
|
||||
export const ProjectIssueDetailsHeader = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, workItem } = useParams();
|
||||
// store hooks
|
||||
const { t } = useTranslation();
|
||||
const { getProjectById, loader } = useProject();
|
||||
const {
|
||||
issue: { getIssueById, getIssueIdByIdentifier },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const issueId = getIssueIdByIdentifier(workItem?.toString());
|
||||
const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined;
|
||||
const projectId = issueDetails ? issueDetails?.project_id : undefined;
|
||||
const projectDetails = projectId ? getProjectById(projectId?.toString()) : undefined;
|
||||
|
||||
if (!workspaceSlug || !projectId || !issueId) return null;
|
||||
|
||||
return (
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<div>
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={projectDetails?.name ?? "Project"}
|
||||
icon={
|
||||
projectDetails ? (
|
||||
projectDetails && (
|
||||
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
|
||||
<Logo logo={projectDetails?.logo_props} size={16} />
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
|
||||
<Briefcase className="h-4 w-4" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues`}
|
||||
label={t("common.work_items")}
|
||||
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={
|
||||
projectDetails && issueDetails ? `${projectDetails.identifier}-${issueDetails.sequence_id}` : ""
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem>
|
||||
{projectId && issueId && (
|
||||
<IssueDetailQuickActions
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={projectId?.toString()}
|
||||
issueId={issueId?.toString()}
|
||||
/>
|
||||
)}
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
import { ProjectIssueDetailsHeader } from "./header";
|
||||
|
||||
export default function ProjectIssueDetailsLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<ProjectIssueDetailsHeader />} />
|
||||
<ContentWrapper className="overflow-hidden">{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { EmptyState } from "@/components/common";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { IssueDetailRoot } from "@/components/issues";
|
||||
// hooks
|
||||
import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store";
|
||||
// assets
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
|
||||
import emptyIssueDark from "@/public/empty-state/search/issues-dark.webp";
|
||||
import emptyIssueLight from "@/public/empty-state/search/issues-light.webp";
|
||||
|
||||
const IssueDetailsPage = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, workItem } = useParams();
|
||||
// hooks
|
||||
const { resolvedTheme } = useTheme();
|
||||
// store hooks
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
fetchIssueWithIdentifier,
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
const { toggleIssueDetailSidebar, issueDetailSidebarCollapsed } = useAppTheme();
|
||||
|
||||
const projectIdentifier = workItem?.toString().split("-")[0];
|
||||
const sequence_id = workItem?.toString().split("-")[1];
|
||||
|
||||
// fetching issue details
|
||||
const { data, isLoading, error } = useSWR(
|
||||
workspaceSlug && workItem ? `ISSUE_DETAIL_${workspaceSlug}_${projectIdentifier}_${sequence_id}` : null,
|
||||
workspaceSlug && workItem
|
||||
? () => fetchIssueWithIdentifier(workspaceSlug.toString(), projectIdentifier, sequence_id)
|
||||
: null
|
||||
);
|
||||
const issueId = data?.id;
|
||||
const projectId = data?.project_id;
|
||||
// derived values
|
||||
const issue = getIssueById(issueId?.toString() || "") || undefined;
|
||||
const project = (issue?.project_id && getProjectById(issue?.project_id)) || undefined;
|
||||
const issueLoader = !issue || isLoading;
|
||||
const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
const handleToggleIssueDetailSidebar = () => {
|
||||
if (window && window.innerWidth < 768) {
|
||||
toggleIssueDetailSidebar(true);
|
||||
}
|
||||
if (window && issueDetailSidebarCollapsed && window.innerWidth >= 768) {
|
||||
toggleIssueDetailSidebar(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("resize", handleToggleIssueDetailSidebar);
|
||||
handleToggleIssueDetailSidebar();
|
||||
return () => window.removeEventListener("resize", handleToggleIssueDetailSidebar);
|
||||
}, [issueDetailSidebarCollapsed, toggleIssueDetailSidebar]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
{error ? (
|
||||
<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(`/${workspaceSlug}/workspace-views/all-issues/`),
|
||||
}}
|
||||
/>
|
||||
) : 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>
|
||||
) : (
|
||||
workspaceSlug &&
|
||||
projectId &&
|
||||
issueId && (
|
||||
<ProjectAuthWrapper workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()}>
|
||||
<IssueDetailRoot
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
issueId={issueId.toString()}
|
||||
is_archived={!!issue?.archived_at}
|
||||
/>
|
||||
</ProjectAuthWrapper>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default IssueDetailsPage;
|
||||
75
web/app/(all)/[workspaceSlug]/(projects)/drafts/header.tsx
Normal file
75
web/app/(all)/[workspaceSlug]/(projects)/drafts/header.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { PenSquare } from "lucide-react";
|
||||
import { EIssuesStoreType, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink, CountChip } from "@/components/common";
|
||||
import { CreateUpdateIssueModal } from "@/components/issues";
|
||||
|
||||
// hooks
|
||||
import { useProject, useUserPermissions, useWorkspaceDraftIssues } from "@/hooks/store";
|
||||
|
||||
export const WorkspaceDraftHeader = observer(() => {
|
||||
// state
|
||||
const [isDraftIssueModalOpen, setIsDraftIssueModalOpen] = useState(false);
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { paginationInfo } = useWorkspaceDraftIssues();
|
||||
const { joinedProjectIds } = useProject();
|
||||
|
||||
const { t } = useTranslation();
|
||||
// check if user is authorized to create draft work item
|
||||
const isAuthorizedUser = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateIssueModal
|
||||
isOpen={isDraftIssueModalOpen}
|
||||
storeType={EIssuesStoreType.WORKSPACE_DRAFT}
|
||||
onClose={() => setIsDraftIssueModalOpen(false)}
|
||||
isDraft
|
||||
/>
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink label={t("drafts")} icon={<PenSquare className="h-4 w-4 text-custom-text-300" />} />
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
{paginationInfo?.total_count && paginationInfo?.total_count > 0 ? (
|
||||
<CountChip count={paginationInfo?.total_count} />
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
</Header.LeftItem>
|
||||
|
||||
<Header.RightItem>
|
||||
{joinedProjectIds && joinedProjectIds.length > 0 && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="items-center gap-1"
|
||||
onClick={() => setIsDraftIssueModalOpen(true)}
|
||||
disabled={!isAuthorizedUser}
|
||||
>
|
||||
{t("workspace_draft_issues.draft_an_issue")}
|
||||
</Button>
|
||||
)}
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
</>
|
||||
);
|
||||
});
|
||||
13
web/app/(all)/[workspaceSlug]/(projects)/drafts/layout.tsx
Normal file
13
web/app/(all)/[workspaceSlug]/(projects)/drafts/layout.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
import { WorkspaceDraftHeader } from "./header";
|
||||
|
||||
export default function WorkspaceDraftLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<WorkspaceDraftHeader />} />
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
27
web/app/(all)/[workspaceSlug]/(projects)/drafts/page.tsx
Normal file
27
web/app/(all)/[workspaceSlug]/(projects)/drafts/page.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { WorkspaceDraftIssuesRoot } from "@/components/issues/workspace-draft";
|
||||
|
||||
const WorkspaceDraftPage = () => {
|
||||
// router
|
||||
const { workspaceSlug: routeWorkspaceSlug } = useParams();
|
||||
const pageTitle = "Workspace Draft";
|
||||
|
||||
// derived values
|
||||
const workspaceSlug = (routeWorkspaceSlug as string) || undefined;
|
||||
|
||||
if (!workspaceSlug) return null;
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="relative h-full w-full overflow-hidden overflow-y-auto">
|
||||
<WorkspaceDraftIssuesRoot workspaceSlug={workspaceSlug} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceDraftPage;
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
"use client";
|
||||
|
||||
import React, { useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui";
|
||||
import { cn, copyUrlToClipboard } from "@plane/utils";
|
||||
// components
|
||||
import { CreateProjectModal } from "@/components/project";
|
||||
import { SidebarProjectsListItem } from "@/components/workspace";
|
||||
// hooks
|
||||
import { orderJoinedProjects } from "@/helpers/project.helper";
|
||||
import { useAppTheme, useProject, useUserPermissions } from "@/hooks/store";
|
||||
import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click";
|
||||
import { TProject } from "@/plane-web/types";
|
||||
|
||||
export const ExtendedProjectSidebar = observer(() => {
|
||||
// refs
|
||||
const extendedProjectSidebarRef = useRef<HTMLDivElement | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
// states
|
||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||||
// routers
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { t } = useTranslation();
|
||||
const { sidebarCollapsed, extendedProjectSidebarCollapsed, toggleExtendedProjectSidebar } = useAppTheme();
|
||||
const { getPartialProjectById, joinedProjectIds: joinedProjects, updateProjectView } = useProject();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
const handleOnProjectDrop = (
|
||||
sourceId: string | undefined,
|
||||
destinationId: string | undefined,
|
||||
shouldDropAtEnd: boolean
|
||||
) => {
|
||||
if (!sourceId || !destinationId || !workspaceSlug) return;
|
||||
if (sourceId === destinationId) return;
|
||||
|
||||
const joinedProjectsList: TProject[] = [];
|
||||
joinedProjects.map((projectId) => {
|
||||
const projectDetails = getPartialProjectById(projectId);
|
||||
if (projectDetails) joinedProjectsList.push(projectDetails);
|
||||
});
|
||||
|
||||
const sourceIndex = joinedProjects.indexOf(sourceId);
|
||||
const destinationIndex = shouldDropAtEnd ? joinedProjects.length : joinedProjects.indexOf(destinationId);
|
||||
|
||||
if (joinedProjectsList.length <= 0) return;
|
||||
|
||||
const updatedSortOrder = orderJoinedProjects(sourceIndex, destinationIndex, sourceId, joinedProjectsList);
|
||||
if (updatedSortOrder != undefined)
|
||||
updateProjectView(workspaceSlug.toString(), sourceId, { sort_order: updatedSortOrder }).catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("error"),
|
||||
message: t("something_went_wrong"),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// filter projects based on search query
|
||||
const filteredProjects = joinedProjects.filter((projectId) => {
|
||||
const project = getPartialProjectById(projectId);
|
||||
if (!project) return false;
|
||||
return project.name.toLowerCase().includes(searchQuery.toLowerCase()) || project.identifier.includes(searchQuery);
|
||||
});
|
||||
|
||||
// auth
|
||||
const isAuthorizedUser = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
useExtendedSidebarOutsideClickDetector(
|
||||
extendedProjectSidebarRef,
|
||||
() => {
|
||||
if (!isProjectModalOpen) {
|
||||
toggleExtendedProjectSidebar(false);
|
||||
}
|
||||
},
|
||||
"extended-project-sidebar-toggle"
|
||||
);
|
||||
|
||||
const handleCopyText = (projectId: string) => {
|
||||
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("link_copied"),
|
||||
message: t("project_link_copied_to_clipboard"),
|
||||
});
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{workspaceSlug && (
|
||||
<CreateProjectModal
|
||||
isOpen={isProjectModalOpen}
|
||||
onClose={() => setIsProjectModalOpen(false)}
|
||||
setToFavorite={false}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={extendedProjectSidebarRef}
|
||||
className={cn(
|
||||
"absolute top-0 h-full z-[19] flex flex-col gap-2 w-[300px] transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 shadow-md",
|
||||
{
|
||||
"translate-x-0 opacity-100 pointer-events-auto": extendedProjectSidebarCollapsed,
|
||||
"-translate-x-full opacity-0 pointer-events-none": !extendedProjectSidebarCollapsed,
|
||||
"left-[70px]": sidebarCollapsed,
|
||||
"left-[250px]": !sidebarCollapsed,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-1 w-full sticky top-4 pt-0 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-custom-text-300 py-1.5">Projects</span>
|
||||
{isAuthorizedUser && (
|
||||
<Tooltip tooltipHeading={t("create_project")} tooltipContent="">
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 rounded hover:bg-custom-sidebar-background-80 flex-shrink-0"
|
||||
onClick={() => {
|
||||
setIsProjectModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1.5 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1 w-full">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-400" />
|
||||
<input
|
||||
className="w-full max-w-[234px] border-none bg-transparent text-sm outline-none placeholder:text-custom-text-400"
|
||||
placeholder={t("search")}
|
||||
value={searchQuery}
|
||||
autoFocus
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 overflow-x-hidden overflow-y-auto vertical-scrollbar scrollbar-sm flex-grow mt-4 px-4">
|
||||
{filteredProjects.map((projectId, index) => (
|
||||
<SidebarProjectsListItem
|
||||
key={projectId}
|
||||
projectId={projectId}
|
||||
handleCopyText={() => handleCopyText(projectId)}
|
||||
projectListType={"JOINED"}
|
||||
disableDrag={false}
|
||||
disableDrop={false}
|
||||
isLastChild={index === joinedProjects.length - 1}
|
||||
handleOnProjectDrop={handleOnProjectDrop}
|
||||
renderInExtendedSidebar
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
126
web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx
Normal file
126
web/app/(all)/[workspaceSlug]/(projects)/extended-sidebar.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { EUserWorkspaceRoles, WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS } from "@plane/constants";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useAppTheme, useWorkspace } from "@/hooks/store";
|
||||
import useExtendedSidebarOutsideClickDetector from "@/hooks/use-extended-sidebar-overview-outside-click";
|
||||
// plane-web imports
|
||||
import { ExtendedSidebarItem } from "@/plane-web/components/workspace/sidebar";
|
||||
|
||||
export const ExtendedAppSidebar = observer(() => {
|
||||
// refs
|
||||
const extendedSidebarRef = useRef<HTMLDivElement | null>(null);
|
||||
// routers
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { sidebarCollapsed, extendedSidebarCollapsed, toggleExtendedSidebar } = useAppTheme();
|
||||
const { updateSidebarPreference, getNavigationPreferences } = useWorkspace();
|
||||
|
||||
// derived values
|
||||
const currentWorkspaceNavigationPreferences = getNavigationPreferences(workspaceSlug.toString());
|
||||
|
||||
const sortedNavigationItems = useMemo(
|
||||
() =>
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.map((item) => {
|
||||
const preference = currentWorkspaceNavigationPreferences?.[item.key];
|
||||
return {
|
||||
...item,
|
||||
sort_order: preference ? preference.sort_order : 0,
|
||||
};
|
||||
}).sort((a, b) => a.sort_order - b.sort_order),
|
||||
[currentWorkspaceNavigationPreferences]
|
||||
);
|
||||
|
||||
const sortedNavigationItemsKeys = sortedNavigationItems.map((item) => item.key);
|
||||
|
||||
const orderNavigationItem = (
|
||||
sourceIndex: number,
|
||||
destinationIndex: number,
|
||||
navigationList: {
|
||||
sort_order: number;
|
||||
key: string;
|
||||
labelTranslationKey: string;
|
||||
href: string;
|
||||
access: EUserWorkspaceRoles[];
|
||||
}[]
|
||||
): number | undefined => {
|
||||
if (sourceIndex < 0 || destinationIndex < 0 || navigationList.length <= 0) return undefined;
|
||||
|
||||
let updatedSortOrder: number | undefined = undefined;
|
||||
const sortOrderDefaultValue = 10000;
|
||||
|
||||
if (destinationIndex === 0) {
|
||||
// updating project at the top of the project
|
||||
const currentSortOrder = navigationList[destinationIndex].sort_order || 0;
|
||||
updatedSortOrder = currentSortOrder - sortOrderDefaultValue;
|
||||
} else if (destinationIndex === navigationList.length) {
|
||||
// updating project at the bottom of the project
|
||||
const currentSortOrder = navigationList[destinationIndex - 1].sort_order || 0;
|
||||
updatedSortOrder = currentSortOrder + sortOrderDefaultValue;
|
||||
} else {
|
||||
// updating project in the middle of the project
|
||||
const destinationTopProjectSortOrder = navigationList[destinationIndex - 1].sort_order || 0;
|
||||
const destinationBottomProjectSortOrder = navigationList[destinationIndex].sort_order || 0;
|
||||
const updatedValue = (destinationTopProjectSortOrder + destinationBottomProjectSortOrder) / 2;
|
||||
updatedSortOrder = updatedValue;
|
||||
}
|
||||
|
||||
return updatedSortOrder;
|
||||
};
|
||||
|
||||
const handleOnNavigationItemDrop = (
|
||||
sourceId: string | undefined,
|
||||
destinationId: string | undefined,
|
||||
shouldDropAtEnd: boolean
|
||||
) => {
|
||||
if (!sourceId || !destinationId || !workspaceSlug) return;
|
||||
if (sourceId === destinationId) return;
|
||||
|
||||
const sourceIndex = sortedNavigationItemsKeys.indexOf(sourceId);
|
||||
const destinationIndex = shouldDropAtEnd
|
||||
? sortedNavigationItemsKeys.length
|
||||
: sortedNavigationItemsKeys.indexOf(destinationId);
|
||||
|
||||
const updatedSortOrder = orderNavigationItem(sourceIndex, destinationIndex, sortedNavigationItems);
|
||||
|
||||
if (updatedSortOrder != undefined)
|
||||
updateSidebarPreference(workspaceSlug.toString(), sourceId, {
|
||||
sort_order: updatedSortOrder,
|
||||
});
|
||||
};
|
||||
|
||||
useExtendedSidebarOutsideClickDetector(
|
||||
extendedSidebarRef,
|
||||
() => toggleExtendedSidebar(false),
|
||||
"extended-sidebar-toggle"
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={extendedSidebarRef}
|
||||
className={cn(
|
||||
"absolute top-0 h-full z-[19] flex flex-col gap-0.5 w-[300px] transform transition-all duration-300 ease-in-out bg-custom-sidebar-background-100 border-r border-custom-sidebar-border-200 p-4 shadow-md pb-6",
|
||||
{
|
||||
"translate-x-0 opacity-100 pointer-events-auto": extendedSidebarCollapsed,
|
||||
"-translate-x-full opacity-0 pointer-events-none": !extendedSidebarCollapsed,
|
||||
"left-[70px]": sidebarCollapsed,
|
||||
"left-[250px]": !sidebarCollapsed,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{sortedNavigationItems.map((item, index) => (
|
||||
<ExtendedSidebarItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
isLastChild={index === sortedNavigationItems.length - 1}
|
||||
handleOnNavigationItemDrop={handleOnNavigationItemDrop}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
64
web/app/(all)/[workspaceSlug]/(projects)/header.tsx
Normal file
64
web/app/(all)/[workspaceSlug]/(projects)/header.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Home } from "lucide-react";
|
||||
// images
|
||||
import githubBlackImage from "/public/logos/github-black.png";
|
||||
import githubWhiteImage from "/public/logos/github-white.png";
|
||||
// ui
|
||||
import { GITHUB_REDIRECTED } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
// constants
|
||||
// hooks
|
||||
import { useEventTracker } from "@/hooks/store";
|
||||
|
||||
export const WorkspaceDashboardHeader = () => {
|
||||
// hooks
|
||||
const { captureEvent } = useEventTracker();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<div>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink label={t("home.title")} icon={<Home className="h-4 w-4 text-custom-text-300" />} />
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem>
|
||||
<a
|
||||
onClick={() =>
|
||||
captureEvent(GITHUB_REDIRECTED, {
|
||||
element: "navbar",
|
||||
})
|
||||
}
|
||||
className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5"
|
||||
href="https://github.com/makeplane/plane"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
src={resolvedTheme === "dark" ? githubWhiteImage : githubBlackImage}
|
||||
height={16}
|
||||
width={16}
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
<span className="hidden text-xs font-medium sm:hidden md:block">{t("home.star_us_on_github")}</span>
|
||||
</a>
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
</>
|
||||
);
|
||||
};
|
||||
23
web/app/(all)/[workspaceSlug]/(projects)/layout.tsx
Normal file
23
web/app/(all)/[workspaceSlug]/(projects)/layout.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"use client";
|
||||
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { AuthenticationWrapper } from "@/lib/wrappers";
|
||||
// plane web components
|
||||
import { WorkspaceAuthWrapper } from "@/plane-web/layouts/workspace-wrapper";
|
||||
import { AppSidebar } from "./sidebar";
|
||||
|
||||
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthenticationWrapper>
|
||||
<CommandPalette />
|
||||
<WorkspaceAuthWrapper>
|
||||
<div className="relative flex h-screen w-full overflow-hidden">
|
||||
<AppSidebar />
|
||||
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</WorkspaceAuthWrapper>
|
||||
</AuthenticationWrapper>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import { NotificationsSidebarRoot } from "@/components/workspace-notifications";
|
||||
|
||||
export default function ProjectInboxIssuesLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="relative w-full h-full overflow-hidden flex items-center">
|
||||
<NotificationsSidebarRoot />
|
||||
<div className="w-full h-full overflow-hidden overflow-y-auto">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
web/app/(all)/[workspaceSlug]/(projects)/notifications/page.tsx
Normal file
123
web/app/(all)/[workspaceSlug]/(projects)/notifications/page.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import { ENotificationLoader, ENotificationQueryParamType } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { SimpleEmptyState } from "@/components/empty-state";
|
||||
import { InboxContentRoot } from "@/components/inbox";
|
||||
import { IssuePeekOverview } from "@/components/issues";
|
||||
// hooks
|
||||
import { useIssueDetail, useUserPermissions, useWorkspace, useWorkspaceNotifications } from "@/hooks/store";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
import { useWorkspaceIssueProperties } from "@/hooks/use-workspace-issue-properties";
|
||||
|
||||
const WorkspaceDashboardPage = observer(() => {
|
||||
const { workspaceSlug } = useParams();
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const {
|
||||
currentSelectedNotificationId,
|
||||
setCurrentSelectedNotificationId,
|
||||
notificationLiteByNotificationId,
|
||||
notificationIdsByWorkspaceId,
|
||||
getNotifications,
|
||||
} = useWorkspaceNotifications();
|
||||
const { fetchUserProjectInfo } = useUserPermissions();
|
||||
const { setPeekIssue } = useIssueDetail();
|
||||
// derived values
|
||||
const pageTitle = currentWorkspace?.name
|
||||
? t("notification.page_label", { workspace: currentWorkspace?.name })
|
||||
: undefined;
|
||||
const { workspace_slug, project_id, issue_id, is_inbox_issue } =
|
||||
notificationLiteByNotificationId(currentSelectedNotificationId);
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/intake/issue-detail" });
|
||||
|
||||
// fetching workspace work item properties
|
||||
useWorkspaceIssueProperties(workspaceSlug);
|
||||
|
||||
// fetch workspace notifications
|
||||
const notificationMutation =
|
||||
currentWorkspace && notificationIdsByWorkspaceId(currentWorkspace.id)
|
||||
? ENotificationLoader.MUTATION_LOADER
|
||||
: ENotificationLoader.INIT_LOADER;
|
||||
const notificationLoader =
|
||||
currentWorkspace && notificationIdsByWorkspaceId(currentWorkspace.id)
|
||||
? ENotificationQueryParamType.CURRENT
|
||||
: ENotificationQueryParamType.INIT;
|
||||
useSWR(
|
||||
currentWorkspace?.slug ? `WORKSPACE_NOTIFICATION` : null,
|
||||
currentWorkspace?.slug
|
||||
? () => getNotifications(currentWorkspace?.slug, notificationMutation, notificationLoader)
|
||||
: null
|
||||
);
|
||||
|
||||
// fetching user project member info
|
||||
const { isLoading: projectMemberInfoLoader } = useSWR(
|
||||
workspace_slug && project_id && is_inbox_issue
|
||||
? `PROJECT_MEMBER_PERMISSION_INFO_${workspace_slug}_${project_id}`
|
||||
: null,
|
||||
workspace_slug && project_id && is_inbox_issue ? () => fetchUserProjectInfo(workspace_slug, project_id) : null
|
||||
);
|
||||
|
||||
const embedRemoveCurrentNotification = useCallback(
|
||||
() => setCurrentSelectedNotificationId(undefined),
|
||||
[setCurrentSelectedNotificationId]
|
||||
);
|
||||
|
||||
// clearing up the selected notifications when unmounting the page
|
||||
useEffect(
|
||||
() => () => {
|
||||
setCurrentSelectedNotificationId(undefined);
|
||||
setPeekIssue(undefined);
|
||||
},
|
||||
[setCurrentSelectedNotificationId, setPeekIssue]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="w-full h-full overflow-hidden overflow-y-auto">
|
||||
{!currentSelectedNotificationId ? (
|
||||
<div className="w-full h-screen flex justify-center items-center">
|
||||
<SimpleEmptyState title={t("notification.empty_state.detail.title")} assetPath={resolvedPath} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{is_inbox_issue === true && workspace_slug && project_id && issue_id ? (
|
||||
<>
|
||||
{projectMemberInfoLoader ? (
|
||||
<div className="w-full h-full flex justify-center items-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
) : (
|
||||
<InboxContentRoot
|
||||
setIsMobileSidebar={() => {}}
|
||||
isMobileSidebar={false}
|
||||
workspaceSlug={workspace_slug}
|
||||
projectId={project_id}
|
||||
inboxIssueId={issue_id}
|
||||
isNotificationEmbed
|
||||
embedRemoveCurrentNotification={embedRemoveCurrentNotification}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<IssuePeekOverview embedIssue embedRemoveCurrentNotification={embedRemoveCurrentNotification} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default WorkspaceDashboardPage;
|
||||
30
web/app/(all)/[workspaceSlug]/(projects)/page.tsx
Normal file
30
web/app/(all)/[workspaceSlug]/(projects)/page.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PageHead, AppHeader, ContentWrapper } from "@/components/core";
|
||||
import { WorkspaceHomeView } from "@/components/home";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
// local components
|
||||
import { WorkspaceDashboardHeader } from "./header";
|
||||
|
||||
const WorkspaceDashboardPage = observer(() => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - ${t("home.title")}` : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<WorkspaceDashboardHeader />} />
|
||||
<ContentWrapper>
|
||||
<PageHead title={pageTitle} />
|
||||
<WorkspaceHomeView />
|
||||
</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default WorkspaceDashboardPage;
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProfileIssuesPage } from "@/components/profile/profile-issues";
|
||||
|
||||
const ProfilePageHeader = {
|
||||
assigned: "Profile - Assigned",
|
||||
created: "Profile - Created",
|
||||
subscribed: "Profile - Subscribed",
|
||||
};
|
||||
|
||||
const ProfileIssuesTypePage = () => {
|
||||
const { profileViewId } = useParams() as { profileViewId: "assigned" | "subscribed" | "created" | undefined };
|
||||
|
||||
if (!profileViewId) return null;
|
||||
|
||||
const header = ProfilePageHeader[profileViewId];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={header} />
|
||||
<ProfileIssuesPage type={profileViewId} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileIssuesTypePage;
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/ui";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { DownloadActivityButton, WorkspaceActivityListPage } from "@/components/profile";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store";
|
||||
// plane-web constants
|
||||
|
||||
const PER_PAGE = 100;
|
||||
|
||||
const ProfileActivityPage = observer(() => {
|
||||
// states
|
||||
const [pageCount, setPageCount] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [resultsCount, setResultsCount] = useState(0);
|
||||
// router
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
//hooks
|
||||
const { t } = useTranslation();
|
||||
|
||||
const updateTotalPages = (count: number) => setTotalPages(count);
|
||||
|
||||
const updateResultsCount = (count: number) => setResultsCount(count);
|
||||
|
||||
const handleLoadMore = () => setPageCount((prev) => prev + 1);
|
||||
|
||||
const activityPages: JSX.Element[] = [];
|
||||
for (let i = 0; i < pageCount; i++)
|
||||
activityPages.push(
|
||||
<WorkspaceActivityListPage
|
||||
key={i}
|
||||
cursor={`${PER_PAGE}:${i}:0`}
|
||||
perPage={PER_PAGE}
|
||||
updateResultsCount={updateResultsCount}
|
||||
updateTotalPages={updateTotalPages}
|
||||
/>
|
||||
);
|
||||
|
||||
const canDownloadActivity = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Profile - Activity" />
|
||||
<div className="flex h-full w-full flex-col overflow-hidden py-5">
|
||||
<div className="flex items-center justify-between gap-2 px-5 md:px-9">
|
||||
<h3 className="text-lg font-medium">{t("profile.stats.recent_activity.title")}</h3>
|
||||
{canDownloadActivity && <DownloadActivityButton />}
|
||||
</div>
|
||||
<div className="vertical-scrollbar scrollbar-md flex h-full flex-col overflow-y-auto px-5 md:px-9">
|
||||
{activityPages}
|
||||
{pageCount < totalPages && resultsCount !== 0 && (
|
||||
<div className="flex w-full items-center justify-center text-xs">
|
||||
<Button variant="accent-primary" size="sm" onClick={handleLoadMore}>
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProfileActivityPage;
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
"use client";
|
||||
|
||||
// ui
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ChevronDown, PanelRight } from "lucide-react";
|
||||
import { PROFILE_VIEWER_TAB, PROFILE_ADMINS_TAB, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IUserProfileProjectSegregation } from "@plane/types";
|
||||
import { Breadcrumbs, Header, CustomMenu, UserActivityIcon } from "@plane/ui";
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
// components
|
||||
import { ProfileIssuesFilter } from "@/components/profile";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { useAppTheme, useUser, useUserPermissions } from "@/hooks/store";
|
||||
|
||||
type TUserProfileHeader = {
|
||||
userProjectsData: IUserProfileProjectSegregation | undefined;
|
||||
type?: string | undefined;
|
||||
showProfileIssuesFilter?: boolean;
|
||||
};
|
||||
|
||||
export const UserProfileHeader: FC<TUserProfileHeader> = observer((props) => {
|
||||
const { userProjectsData, type = undefined, showProfileIssuesFilter } = props;
|
||||
// router
|
||||
const { workspaceSlug, userId } = useParams();
|
||||
// store hooks
|
||||
const { toggleProfileSidebar, profileSidebarCollapsed } = useAppTheme();
|
||||
const { data: currentUser } = useUser();
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const isAuthorized = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
if (!workspaceUserInfo) return null;
|
||||
|
||||
const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB;
|
||||
|
||||
const userName = `${userProjectsData?.user_data?.first_name} ${userProjectsData?.user_data?.last_name}`;
|
||||
|
||||
const isCurrentUser = currentUser?.id === userId;
|
||||
|
||||
const breadcrumbLabel = isCurrentUser ? t("profile.page_label") : `${userName} ${t("profile.work")}`;
|
||||
|
||||
return (
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={breadcrumbLabel}
|
||||
disableTooltip
|
||||
icon={<UserActivityIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem>
|
||||
<div className="hidden md:flex md:items-center">{showProfileIssuesFilter && <ProfileIssuesFilter />}</div>
|
||||
<div className="flex gap-4 md:hidden">
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className="flex flex-grow justify-center text-sm text-custom-text-200"
|
||||
placement="bottom-start"
|
||||
customButton={
|
||||
<div className="flex items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1.5">
|
||||
<span className="flex flex-grow justify-center text-sm text-custom-text-200">{type}</span>
|
||||
<ChevronDown className="h-4 w-4 text-custom-text-400" />
|
||||
</div>
|
||||
}
|
||||
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||
closeOnSelect
|
||||
>
|
||||
<></>
|
||||
{tabsList.map((tab) => (
|
||||
<CustomMenu.MenuItem className="flex items-center gap-2" key={tab.route}>
|
||||
<Link
|
||||
key={tab.route}
|
||||
href={`/${workspaceSlug}/profile/${userId}/${tab.route}`}
|
||||
className="w-full text-custom-text-300"
|
||||
>
|
||||
{t(tab.i18n_label)}
|
||||
</Link>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
<button
|
||||
className="block transition-all md:hidden"
|
||||
onClick={() => {
|
||||
toggleProfileSidebar();
|
||||
}}
|
||||
>
|
||||
<PanelRight
|
||||
className={cn(
|
||||
"block h-4 w-4 md:hidden",
|
||||
!profileSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { PROFILE_VIEWER_TAB, PROFILE_ADMINS_TAB, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
import { ProfileSidebar } from "@/components/profile";
|
||||
// constants
|
||||
import { USER_PROFILE_PROJECT_SEGREGATION } from "@/constants/fetch-keys";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store";
|
||||
import useSize from "@/hooks/use-window-size";
|
||||
// local components
|
||||
import { UserService } from "@/services/user.service";
|
||||
import { UserProfileHeader } from "./header";
|
||||
import { ProfileIssuesMobileHeader } from "./mobile-header";
|
||||
import { ProfileNavbar } from "./navbar";
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const UseProfileLayout: React.FC<Props> = observer((props) => {
|
||||
const { children } = props;
|
||||
// router
|
||||
const { workspaceSlug, userId } = useParams();
|
||||
const pathname = usePathname();
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const isAuthorized = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
const windowSize = useSize();
|
||||
const isSmallerScreen = windowSize[0] >= 768;
|
||||
|
||||
const { data: userProjectsData } = useSWR(
|
||||
workspaceSlug && userId ? USER_PROFILE_PROJECT_SEGREGATION(workspaceSlug.toString(), userId.toString()) : null,
|
||||
workspaceSlug && userId
|
||||
? () => userService.getUserProfileProjectsSegregation(workspaceSlug.toString(), userId.toString())
|
||||
: null
|
||||
);
|
||||
// derived values
|
||||
const isAuthorizedPath =
|
||||
pathname.includes("assigned") || pathname.includes("created") || pathname.includes("subscribed");
|
||||
const isIssuesTab = pathname.includes("assigned") || pathname.includes("created") || pathname.includes("subscribed");
|
||||
|
||||
const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB;
|
||||
const currentTab = tabsList.find((tab) => pathname === `/${workspaceSlug}/profile/${userId}${tab.selected}`);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Passing the type prop from the current route value as we need the header as top most component.
|
||||
TODO: We are depending on the route path to handle the mobile header type. If the path changes, this logic will break. */}
|
||||
<div className="h-full w-full flex flex-col md:flex-row overflow-hidden">
|
||||
<div className="h-full w-full flex flex-col overflow-hidden">
|
||||
<AppHeader
|
||||
header={
|
||||
<UserProfileHeader
|
||||
type={currentTab?.i18n_label}
|
||||
userProjectsData={userProjectsData}
|
||||
showProfileIssuesFilter={isIssuesTab}
|
||||
/>
|
||||
}
|
||||
mobileHeader={isIssuesTab && <ProfileIssuesMobileHeader />}
|
||||
/>
|
||||
<ContentWrapper>
|
||||
<div className="h-full w-full flex flex-row md:flex-col md:overflow-hidden">
|
||||
<div className="flex w-full flex-col md:h-full md:overflow-hidden">
|
||||
<ProfileNavbar isAuthorized={!!isAuthorized} />
|
||||
{isAuthorized || !isAuthorizedPath ? (
|
||||
<div className={`w-full overflow-hidden h-full`}>{children}</div>
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center text-custom-text-200">
|
||||
{t("you_do_not_have_the_permission_to_access_this_page")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isSmallerScreen && <ProfileSidebar userProjectsData={userProjectsData} />}
|
||||
</div>
|
||||
</ContentWrapper>
|
||||
</div>
|
||||
{isSmallerScreen && <ProfileSidebar userProjectsData={userProjectsData} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default UseProfileLayout;
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// icons
|
||||
import { ChevronDown } from "lucide-react";
|
||||
// plane constants
|
||||
import {
|
||||
EIssueLayoutTypes,
|
||||
EIssueFilterType,
|
||||
EIssuesStoreType,
|
||||
ISSUE_LAYOUTS,
|
||||
ISSUE_DISPLAY_FILTERS_BY_PAGE,
|
||||
} from "@plane/constants";
|
||||
// plane i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown, IssueLayoutIcon } from "@/components/issues";
|
||||
|
||||
// helpers
|
||||
import { isIssueFilterActive } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import { useIssues, useLabel } from "@/hooks/store";
|
||||
|
||||
export const ProfileIssuesMobileHeader = observer(() => {
|
||||
// plane i18n
|
||||
const { t } = useTranslation();
|
||||
// router
|
||||
const { workspaceSlug, userId } = useParams();
|
||||
// store hook
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.PROFILE);
|
||||
|
||||
const { workspaceLabels } = useLabel();
|
||||
// derived values
|
||||
const states = undefined;
|
||||
// const members = undefined;
|
||||
// const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
// const states = undefined;
|
||||
const members = undefined;
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: TIssueLayouts) => {
|
||||
if (!workspaceSlug || !userId) return;
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
undefined,
|
||||
EIssueFilterType.DISPLAY_FILTERS,
|
||||
{ layout: layout as EIssueLayoutTypes | undefined },
|
||||
userId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, updateFilters, userId]
|
||||
);
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !userId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
undefined,
|
||||
EIssueFilterType.FILTERS,
|
||||
{ [key]: newValues },
|
||||
userId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, issueFilters, updateFilters, userId]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !userId) return;
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
undefined,
|
||||
EIssueFilterType.DISPLAY_FILTERS,
|
||||
updatedDisplayFilter,
|
||||
userId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, updateFilters, userId]
|
||||
);
|
||||
|
||||
const handleDisplayProperties = useCallback(
|
||||
(property: Partial<IIssueDisplayProperties>) => {
|
||||
if (!workspaceSlug || !userId) return;
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
undefined,
|
||||
EIssueFilterType.DISPLAY_PROPERTIES,
|
||||
property,
|
||||
userId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, updateFilters, userId]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex justify-evenly border-b border-custom-border-200 py-2 md:hidden">
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className="flex flex-grow justify-center text-sm text-custom-text-200"
|
||||
placement="bottom-start"
|
||||
customButton={
|
||||
<div className="flex flex-center text-sm text-custom-text-200">
|
||||
{t("common.layout")}
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200 my-auto" strokeWidth={2} />
|
||||
</div>
|
||||
}
|
||||
customButtonClassName="flex flex-center text-custom-text-200 text-sm"
|
||||
closeOnSelect
|
||||
>
|
||||
{ISSUE_LAYOUTS.map((layout, index) => {
|
||||
if (layout.key === "spreadsheet" || layout.key === "gantt_chart" || layout.key === "calendar") return;
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={index}
|
||||
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-custom-text-300">{t(layout.i18n_title)}</div>
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<FiltersDropdown
|
||||
title={t("common.filters")}
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<div className="flex flex-center text-sm text-custom-text-200">
|
||||
{t("common.filters")}
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" strokeWidth={2} />
|
||||
</div>
|
||||
}
|
||||
isFiltersApplied={isIssueFilterActive(issueFilters)}
|
||||
>
|
||||
<FilterSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.profile_issues[activeLayout] : undefined
|
||||
}
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
states={states}
|
||||
labels={workspaceLabels}
|
||||
memberIds={members}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<FiltersDropdown
|
||||
title={t("common.display")}
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<div className="flex flex-center text-sm text-custom-text-200">
|
||||
{t("common.display")}
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" strokeWidth={2} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.profile_issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { PROFILE_VIEWER_TAB, PROFILE_ADMINS_TAB } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
|
||||
// components
|
||||
// constants
|
||||
import { Header, EHeaderVariant } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
isAuthorized: boolean;
|
||||
};
|
||||
|
||||
export const ProfileNavbar: React.FC<Props> = (props) => {
|
||||
const { isAuthorized } = props;
|
||||
const { t } = useTranslation();
|
||||
const { workspaceSlug, userId } = useParams();
|
||||
const pathname = usePathname();
|
||||
|
||||
const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB;
|
||||
|
||||
return (
|
||||
<Header variant={EHeaderVariant.SECONDARY} showOnMobile={false}>
|
||||
<div className="flex items-center overflow-x-scroll">
|
||||
{tabsList.map((tab) => (
|
||||
<Link key={tab.route} href={`/${workspaceSlug}/profile/${userId}/${tab.route}`}>
|
||||
<span
|
||||
className={`flex whitespace-nowrap border-b-2 p-4 text-sm font-medium outline-none ${
|
||||
pathname === `/${workspaceSlug}/profile/${userId}${tab.selected}`
|
||||
? "border-custom-primary-100 text-custom-primary-100"
|
||||
: "border-transparent"
|
||||
}`}
|
||||
>
|
||||
{t(tab.i18n_label)}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Header>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// types
|
||||
import { GROUP_CHOICES } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IUserStateDistribution, TStateGroups } from "@plane/types";
|
||||
// components
|
||||
import { ContentWrapper } from "@plane/ui";
|
||||
import { PageHead } from "@/components/core";
|
||||
import {
|
||||
ProfileActivity,
|
||||
ProfilePriorityDistribution,
|
||||
ProfileStateDistribution,
|
||||
ProfileStats,
|
||||
ProfileWorkload,
|
||||
} from "@/components/profile";
|
||||
// constants
|
||||
import { USER_PROFILE_DATA } from "@/constants/fetch-keys";
|
||||
// services
|
||||
import { UserService } from "@/services/user.service";
|
||||
|
||||
// services
|
||||
const userService = new UserService();
|
||||
|
||||
export default function ProfileOverviewPage() {
|
||||
const { workspaceSlug, userId } = useParams();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { data: userProfile } = useSWR(
|
||||
workspaceSlug && userId ? USER_PROFILE_DATA(workspaceSlug.toString(), userId.toString()) : null,
|
||||
workspaceSlug && userId ? () => userService.getUserProfileData(workspaceSlug.toString(), userId.toString()) : null
|
||||
);
|
||||
|
||||
const stateDistribution: IUserStateDistribution[] = Object.keys(GROUP_CHOICES).map((key) => {
|
||||
const group = userProfile?.state_distribution.find((g) => g.state_group === key);
|
||||
|
||||
if (group) return group;
|
||||
else return { state_group: key as TStateGroups, state_count: 0 };
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t("profile.page_label")} />
|
||||
<ContentWrapper className="space-y-7">
|
||||
<ProfileStats userProfile={userProfile} />
|
||||
<ProfileWorkload stateDistribution={stateDistribution} />
|
||||
<div className="grid grid-cols-1 items-stretch gap-5 xl:grid-cols-2">
|
||||
<ProfilePriorityDistribution userProfile={userProfile} />
|
||||
<ProfileStateDistribution stateDistribution={stateDistribution} userProfile={userProfile} />
|
||||
</div>
|
||||
<ProfileActivity />
|
||||
</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
import { ProjectArchivesHeader } from "../header";
|
||||
|
||||
export default function ProjectArchiveCyclesLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<ProjectArchivesHeader activeTab="cycles" />} />
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ArchivedCycleLayoutRoot, ArchivedCyclesHeader } from "@/components/cycles";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
|
||||
const ProjectArchivedCyclesPage = observer(() => {
|
||||
// router
|
||||
const { projectId } = useParams();
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const project = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
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 ProjectArchivedCyclesPage;
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { EIssuesStoreType } from "@plane/constants";
|
||||
// ui
|
||||
import { ArchiveIcon, Breadcrumbs, Tooltip, Header, ContrastIcon, DiceIcon, LayersIcon } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
// hooks
|
||||
import { useIssues, useProject } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
|
||||
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: LayersIcon,
|
||||
},
|
||||
cycles: {
|
||||
label: "Cycles",
|
||||
href: "/cycles",
|
||||
icon: ContrastIcon,
|
||||
},
|
||||
modules: {
|
||||
label: "Modules",
|
||||
href: "/modules",
|
||||
icon: DiceIcon,
|
||||
},
|
||||
};
|
||||
|
||||
export const ProjectArchivesHeader: FC<TProps> = observer((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"}>
|
||||
<ProjectBreadcrumb />
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/archives/issues`}
|
||||
label="Archives"
|
||||
icon={<ArchiveIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{activeTabBreadcrumbDetail && (
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={activeTabBreadcrumbDetail.label}
|
||||
icon={<activeTabBreadcrumbDetail.icon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Breadcrumbs>
|
||||
{activeTab === "issues" && issueCount && issueCount > 0 ? (
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "work items" : "work item"} in project's archived`}
|
||||
position="bottom"
|
||||
>
|
||||
<span className="cursor-default flex items-center text-center justify-center px-2.5 py-0.5 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
|
||||
{issueCount}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
</Header.LeftItem>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { IssueDetailRoot } from "@/components/issues";
|
||||
// constants
|
||||
// hooks
|
||||
import { useIssueDetail, useProject } from "@/hooks/store";
|
||||
|
||||
const ArchivedIssueDetailsPage = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, projectId, archivedIssueId } = useParams();
|
||||
// states
|
||||
// hooks
|
||||
const {
|
||||
fetchIssue,
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
|
||||
const { getProjectById } = useProject();
|
||||
|
||||
const { isLoading } = useSWR(
|
||||
workspaceSlug && projectId && archivedIssueId
|
||||
? `ARCHIVED_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${archivedIssueId}`
|
||||
: null,
|
||||
workspaceSlug && projectId && archivedIssueId
|
||||
? () => fetchIssue(workspaceSlug.toString(), projectId.toString(), archivedIssueId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
// derived values
|
||||
const issue = archivedIssueId ? getIssueById(archivedIssueId.toString()) : undefined;
|
||||
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>
|
||||
) : (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<div className="h-full w-full space-y-3 divide-y-2 divide-custom-border-200 overflow-y-auto">
|
||||
{workspaceSlug && projectId && archivedIssueId && (
|
||||
<IssueDetailRoot
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
issueId={archivedIssueId.toString()}
|
||||
is_archived
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ArchivedIssueDetailsPage;
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// ui
|
||||
import { ArchiveIcon, Breadcrumbs, LayersIcon, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
import { IssueDetailQuickActions } from "@/components/issues";
|
||||
// constants
|
||||
import { ISSUE_DETAILS } from "@/constants/fetch-keys";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
// plane web
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
// services
|
||||
import { IssueService } from "@/services/issue";
|
||||
|
||||
const issueService = new IssueService();
|
||||
|
||||
export const ProjectArchivedIssueDetailsHeader = observer(() => {
|
||||
// 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 />
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/archives/issues`}
|
||||
label="Archives"
|
||||
icon={<ArchiveIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/archives/issues`}
|
||||
label="Work items"
|
||||
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
import { ProjectArchivedIssueDetailsHeader } from "./header";
|
||||
|
||||
export default function ProjectArchivedIssueDetailLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<ProjectArchivedIssueDetailsHeader />} />
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
import { ProjectArchivesHeader } from "../../header";
|
||||
|
||||
export default function ProjectArchiveIssuesLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<ProjectArchivesHeader activeTab="issues" />} />
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ArchivedIssueLayoutRoot, ArchivedIssuesHeader } from "@/components/issues";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
|
||||
const ProjectArchivedIssuesPage = observer(() => {
|
||||
// router
|
||||
const { projectId } = useParams();
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const project = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
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 ProjectArchivedIssuesPage;
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
import { ProjectArchivesHeader } from "../header";
|
||||
|
||||
export default function ProjectArchiveModulesLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<ProjectArchivesHeader activeTab="modules" />} />
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ArchivedModuleLayoutRoot, ArchivedModulesHeader } from "@/components/modules";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
|
||||
const ProjectArchivedModulesPage = observer(() => {
|
||||
// router
|
||||
const { projectId } = useParams();
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const project = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
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 ProjectArchivedModulesPage;
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// components
|
||||
import { EmptyState } from "@/components/common";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { CycleDetailsSidebar } from "@/components/cycles";
|
||||
import useCyclesDetails from "@/components/cycles/active-cycle/use-cycles-details";
|
||||
import { CycleLayoutRoot } from "@/components/issues/issue-layouts";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useCycle, useProject } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
// assets
|
||||
import emptyCycle from "@/public/empty-state/cycle.svg";
|
||||
|
||||
const CycleDetailPage = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, projectId, cycleId } = useParams();
|
||||
// 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: workspaceSlug?.toString(),
|
||||
projectId: projectId.toString(),
|
||||
cycleId: cycleId.toString(),
|
||||
});
|
||||
// derived values
|
||||
const isSidebarCollapsed = storedValue ? (storedValue === true ? true : false) : false;
|
||||
const cycle = cycleId ? getCycleById(cycleId.toString()) : undefined;
|
||||
const project = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
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>
|
||||
{cycleId && !isSidebarCollapsed && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-[21.5rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-4 duration-300 vertical-scrollbar scrollbar-sm absolute right-0 z-[13]"
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",
|
||||
}}
|
||||
>
|
||||
<CycleDetailsSidebar
|
||||
handleClose={toggleSidebar}
|
||||
cycleId={cycleId.toString()}
|
||||
projectId={projectId.toString()}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default CycleDetailPage;
|
||||
|
|
@ -0,0 +1,319 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
// icons
|
||||
import { PanelRight } from "lucide-react";
|
||||
// plane constants
|
||||
import {
|
||||
EIssueLayoutTypes,
|
||||
EIssueFilterType,
|
||||
EIssuesStoreType,
|
||||
ISSUE_DISPLAY_FILTERS_BY_PAGE,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
} from "@plane/constants";
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import {
|
||||
ICustomSearchSelectOption,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
} from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, ContrastIcon, Tooltip, Header, CustomSearchSelect } from "@plane/ui";
|
||||
// components
|
||||
import { WorkItemsModal } from "@/components/analytics-v2/work-items/modal";
|
||||
import { BreadcrumbLink, SwitcherLabel } from "@/components/common";
|
||||
import { CycleQuickActions } from "@/components/cycles";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { isIssueFilterActive } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import {
|
||||
useEventTracker,
|
||||
useCycle,
|
||||
useLabel,
|
||||
useMember,
|
||||
useProject,
|
||||
useProjectState,
|
||||
useIssues,
|
||||
useCommandPalette,
|
||||
useUserPermissions,
|
||||
} from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
|
||||
export const CycleIssuesHeader: React.FC = observer(() => {
|
||||
// refs
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
// states
|
||||
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, projectId, cycleId } = useParams() as {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
cycleId: string;
|
||||
};
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
issues: { getGroupIssueCount },
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
const { currentProjectCycleIds, getCycleById } = useCycle();
|
||||
const { toggleCreateIssueModal } = useCommandPalette();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { currentProjectDetails, loader } = useProject();
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", false);
|
||||
|
||||
const isSidebarCollapsed = storedValue ? (storedValue === true ? true : false) : false;
|
||||
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 handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }, cycleId);
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, issueFilters, 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={ContrastIcon} />,
|
||||
};
|
||||
})
|
||||
.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"}>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<span>
|
||||
<span className="hidden md:block">
|
||||
<ProjectBreadcrumb />
|
||||
</span>
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
|
||||
className="block pl-2 text-custom-text-300 md:hidden"
|
||||
>
|
||||
...
|
||||
</Link>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={t("common.cycles")}
|
||||
href={`/${workspaceSlug}/projects/${projectId}/cycles`}
|
||||
icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="component"
|
||||
component={
|
||||
<CustomSearchSelect
|
||||
options={switcherOptions}
|
||||
value={cycleId}
|
||||
onChange={(value: string) => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${value}`);
|
||||
}}
|
||||
label={
|
||||
<div className="flex items-center gap-1">
|
||||
<SwitcherLabel name={cycleDetails?.name} LabelIcon={ContrastIcon} />
|
||||
{workItemsCount && workItemsCount > 0 ? (
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={`There are ${workItemsCount} ${
|
||||
workItemsCount > 1 ? "work items" : "work item"
|
||||
} in this cycle`}
|
||||
position="bottom"
|
||||
>
|
||||
<span className="flex flex-shrink-0 cursor-default items-center justify-center rounded-xl bg-custom-primary-100/20 px-2 text-center text-xs font-semibold text-custom-primary-100">
|
||||
{workItemsCount}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem className="items-center">
|
||||
<div className="hidden items-center gap-2 md:flex ">
|
||||
<LayoutSelection
|
||||
layouts={[
|
||||
EIssueLayoutTypes.LIST,
|
||||
EIssueLayoutTypes.KANBAN,
|
||||
EIssueLayoutTypes.CALENDAR,
|
||||
EIssueLayoutTypes.SPREADSHEET,
|
||||
EIssueLayoutTypes.GANTT,
|
||||
]}
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
<FiltersDropdown
|
||||
title={t("common.filters")}
|
||||
placement="bottom-end"
|
||||
isFiltersApplied={isIssueFilterActive(issueFilters)}
|
||||
>
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
<FiltersDropdown title={t("common.display")} placement="bottom-end">
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[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="neutral-primary" size="sm">
|
||||
{t("common.analytics")}
|
||||
</Button>
|
||||
{!isCompletedCycle && (
|
||||
<Button
|
||||
className="h-full self-start"
|
||||
onClick={() => {
|
||||
setTrackElement("Cycle work items page");
|
||||
toggleCreateIssueModal(true, EIssuesStoreType.CYCLE);
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
{t("issue.add.label")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded outline-none hover:bg-custom-sidebar-background-80 bg-custom-background-80/70"
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<PanelRight className={cn("h-4 w-4", !isSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200")} />
|
||||
</button>
|
||||
<CycleQuickActions
|
||||
parentRef={parentRef}
|
||||
cycleId={cycleId}
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
customClassName="flex-shrink-0 flex items-center justify-center size-[26px] bg-custom-background-80/70 rounded"
|
||||
/>
|
||||
</div>
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
import { CycleIssuesHeader } from "./header";
|
||||
import { CycleIssuesMobileHeader } from "./mobile-header";
|
||||
|
||||
export default function ProjectCycleIssuesLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<CycleIssuesHeader />} mobileHeader={<CycleIssuesMobileHeader />} />
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
// icons
|
||||
import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
|
||||
// plane constants
|
||||
import {
|
||||
EIssueLayoutTypes,
|
||||
EIssueFilterType,
|
||||
EIssuesStoreType,
|
||||
ISSUE_LAYOUTS,
|
||||
ISSUE_DISPLAY_FILTERS_BY_PAGE,
|
||||
} from "@plane/constants";
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
import { WorkItemsModal } from "@/components/analytics-v2/work-items/modal";
|
||||
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown, IssueLayoutIcon } from "@/components/issues";
|
||||
// helpers
|
||||
import { isIssueFilterActive } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import { useIssues, useCycle, useProjectState, useLabel, useMember, useProject } from "@/hooks/store";
|
||||
|
||||
export const CycleIssuesMobileHeader = () => {
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||
const { getCycleById } = useCycle();
|
||||
const layouts = [
|
||||
{ key: "list", titleTranslationKey: "issue.layouts.list", icon: List },
|
||||
{ key: "kanban", titleTranslationKey: "issue.layouts.kanban", icon: Kanban },
|
||||
{ key: "calendar", titleTranslationKey: "issue.layouts.calendar", icon: Calendar },
|
||||
];
|
||||
|
||||
const { workspaceSlug, projectId, cycleId } = useParams();
|
||||
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
|
||||
|
||||
// store hooks
|
||||
const { currentProjectDetails } = useProject();
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
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 { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId || !cycleId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.FILTERS,
|
||||
{ [key]: newValues },
|
||||
cycleId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, issueFilters, 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 py-2 border-b border-custom-border-200 md:hidden bg-custom-background-100">
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||
placement="bottom-start"
|
||||
customButton={
|
||||
<span className="flex flex-grow justify-center text-custom-text-200 text-sm">{t("common.layout")}</span>
|
||||
}
|
||||
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||
closeOnSelect
|
||||
>
|
||||
{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="w-3 h-3" />
|
||||
<div className="text-custom-text-300">{t(layout.titleTranslationKey)}</div>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
<div className="flex flex-grow justify-center border-l border-custom-border-200 items-center text-custom-text-200 text-sm">
|
||||
<FiltersDropdown
|
||||
title={t("common.filters")}
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<span className="flex items-center text-custom-text-200 text-sm">
|
||||
{t("common.filters")}
|
||||
<ChevronDown className="text-custom-text-200 h-4 w-4 ml-2" />
|
||||
</span>
|
||||
}
|
||||
isFiltersApplied={isIssueFilterActive(issueFilters)}
|
||||
>
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
<div className="flex flex-grow justify-center border-l border-custom-border-200 items-center text-custom-text-200 text-sm">
|
||||
<FiltersDropdown
|
||||
title={t("common.display")}
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<span className="flex items-center text-custom-text-200 text-sm">
|
||||
{t("common.display")}
|
||||
<ChevronDown className="text-custom-text-200 h-4 w-4 ml-2" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[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 text-custom-text-200 text-sm border-l border-custom-border-200"
|
||||
>
|
||||
{t("common.analytics")}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs, Button, ContrastIcon, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
import { CyclesViewHeader } from "@/components/cycles";
|
||||
// hooks
|
||||
import { useCommandPalette, useEventTracker, useProject, useUserPermissions } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane web
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
// constants
|
||||
|
||||
export const CyclesListHeader: FC = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
const { toggleCreateCycleModal } = useCommandPalette();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
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"}>
|
||||
<ProjectBreadcrumb />
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={t("cycle.label", { count: 2 })}
|
||||
icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</Header.LeftItem>
|
||||
{canUserCreateCycle && currentProjectDetails ? (
|
||||
<Header.RightItem>
|
||||
<CyclesViewHeader projectId={currentProjectDetails.id} />
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTrackElement("Cycles page");
|
||||
toggleCreateCycleModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="sm:hidden block">{t("add")}</div>
|
||||
<div className="hidden sm:block">{t("project_cycles.add_cycle")}</div>
|
||||
</Button>
|
||||
</Header.RightItem>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
import { CyclesListHeader } from "./header";
|
||||
import { CyclesListMobileHeader } from "./mobile-header";
|
||||
|
||||
export default function ProjectCyclesListLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<CyclesListHeader />} mobileHeader={<CyclesListMobileHeader />} />
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { GanttChartSquare, LayoutGrid, List } from "lucide-react";
|
||||
// plane package imports
|
||||
import { TCycleLayoutOptions } from "@plane/types";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// hooks
|
||||
import { useCycleFilter, useProject } from "@/hooks/store";
|
||||
|
||||
const CYCLE_VIEW_LAYOUTS: {
|
||||
key: TCycleLayoutOptions;
|
||||
icon: any;
|
||||
title: string;
|
||||
}[] = [
|
||||
{
|
||||
key: "list",
|
||||
icon: List,
|
||||
title: "List layout",
|
||||
},
|
||||
{
|
||||
key: "board",
|
||||
icon: LayoutGrid,
|
||||
title: "Gallery layout",
|
||||
},
|
||||
{
|
||||
key: "gantt",
|
||||
icon: GanttChartSquare,
|
||||
title: "Timeline layout",
|
||||
},
|
||||
];
|
||||
|
||||
export const CyclesListMobileHeader = observer(() => {
|
||||
const { currentProjectDetails } = useProject();
|
||||
// hooks
|
||||
const { updateDisplayFilters } = useCycleFilter();
|
||||
return (
|
||||
<div className="flex justify-center sm:hidden">
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className="flex flex-grow justify-center text-custom-text-200 text-sm py-2 border-b border-custom-border-200 bg-custom-sidebar-background-100"
|
||||
// placement="bottom-start"
|
||||
customButton={
|
||||
<span className="flex items-center gap-2">
|
||||
<List className="h-4 w-4" />
|
||||
<span className="flex flex-grow justify-center text-custom-text-200 text-sm">Layout</span>
|
||||
</span>
|
||||
}
|
||||
customButtonClassName="flex flex-grow justify-center items-center text-custom-text-200 text-sm"
|
||||
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="w-3 h-3" />
|
||||
<div className="text-custom-text-300">{layout.title}</div>
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { EUserPermissionsLevel, EUserProjectRoles } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TCycleFilters } from "@plane/types";
|
||||
// components
|
||||
import { Header, EHeaderVariant } from "@plane/ui";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { CyclesView, CycleCreateUpdateModal, CycleAppliedFiltersList } from "@/components/cycles";
|
||||
import { ComicBoxButton, DetailedEmptyState } from "@/components/empty-state";
|
||||
import { CycleModuleListLayout } from "@/components/ui";
|
||||
// helpers
|
||||
import { calculateTotalFilters } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useCycle, useProject, useCycleFilter, useUserPermissions } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
|
||||
const ProjectCyclesPage = observer(() => {
|
||||
// states
|
||||
const [createModal, setCreateModal] = useState(false);
|
||||
// store hooks
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { currentProjectCycleIds, loader } = useCycle();
|
||||
const { getProjectById, currentProjectDetails } = useProject();
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// cycle filters hook
|
||||
const { clearAllFilters, currentProjectFilters, updateFilters } = useCycleFilter();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// derived values
|
||||
const totalCycles = currentProjectCycleIds?.length ?? 0;
|
||||
const project = projectId ? getProjectById(projectId?.toString()) : undefined;
|
||||
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 resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/cycles" });
|
||||
|
||||
const handleRemoveFilter = (key: keyof TCycleFilters, value: string | null) => {
|
||||
if (!projectId) return;
|
||||
let newValues = currentProjectFilters?.[key] ?? [];
|
||||
|
||||
if (!value) newValues = [];
|
||||
else newValues = newValues.filter((val) => val !== value);
|
||||
|
||||
updateFilters(projectId.toString(), { [key]: newValues });
|
||||
};
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
|
||||
// No access to cycle
|
||||
if (currentProjectDetails?.cycle_view === false)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<DetailedEmptyState
|
||||
title={t("disabled_project.empty_state.cycle.title")}
|
||||
description={t("disabled_project.empty_state.cycle.description")}
|
||||
assetPath={resolvedPath}
|
||||
primaryButton={{
|
||||
text: t("disabled_project.empty_state.cycle.primary_button.text"),
|
||||
onClick: () => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/settings/features`);
|
||||
},
|
||||
disabled: !hasAdminLevelPermission,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loader) return <CycleModuleListLayout />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="w-full h-full">
|
||||
<CycleCreateUpdateModal
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
isOpen={createModal}
|
||||
handleClose={() => setCreateModal(false)}
|
||||
/>
|
||||
{totalCycles === 0 ? (
|
||||
<div className="h-full place-items-center">
|
||||
<DetailedEmptyState
|
||||
title={t("project_cycles.empty_state.general.title")}
|
||||
description={t("project_cycles.empty_state.general.description")}
|
||||
assetPath={resolvedPath}
|
||||
customPrimaryButton={
|
||||
<ComicBoxButton
|
||||
label={t("project_cycles.empty_state.general.primary_button.text")}
|
||||
title={t("project_cycles.empty_state.general.primary_button.comic.title")}
|
||||
description={t("project_cycles.empty_state.general.primary_button.comic.description")}
|
||||
onClick={() => {
|
||||
setTrackElement("Cycle empty state");
|
||||
setCreateModal(true);
|
||||
}}
|
||||
disabled={!hasMemberLevelPermission}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{calculateTotalFilters(currentProjectFilters ?? {}) !== 0 && (
|
||||
<Header variant={EHeaderVariant.TERNARY}>
|
||||
<CycleAppliedFiltersList
|
||||
appliedFilters={currentProjectFilters ?? {}}
|
||||
handleClearAllFilters={() => clearAllFilters(projectId.toString())}
|
||||
handleRemoveFilter={handleRemoveFilter}
|
||||
/>
|
||||
</Header>
|
||||
)}
|
||||
|
||||
<CyclesView workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectCyclesPage;
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
"use client";
|
||||
|
||||
import { FC, useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane constants
|
||||
import { EIssueLayoutTypes, EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_PAGE } from "@plane/constants";
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
|
||||
// helpers
|
||||
import { isIssueFilterActive } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import { useIssues, useLabel, useMember, useProject, useProjectState } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
|
||||
// FIXME: Deprecated. Remove it
|
||||
export const ProjectDraftIssueHeader: FC = observer(() => {
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string };
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.DRAFT);
|
||||
const { currentProjectDetails, loader } = useProject();
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues });
|
||||
},
|
||||
[workspaceSlug, projectId, issueFilters, updateFilters]
|
||||
);
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
const issueCount = undefined;
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
|
||||
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Breadcrumbs isLoading={loader === "init-loader"}>
|
||||
<ProjectBreadcrumb />
|
||||
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label="Draft work items"
|
||||
icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
{issueCount && issueCount > 0 ? (
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "work items" : "work item"} in project's draft`}
|
||||
position="bottom"
|
||||
>
|
||||
<span className="cursor-default flex items-center text-center justify-center px-2.5 py-0.5 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
|
||||
{issueCount}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<LayoutSelection
|
||||
layouts={[EIssueLayoutTypes.LIST, EIssueLayoutTypes.KANBAN]}
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
<FiltersDropdown
|
||||
title={t("common.filters")}
|
||||
placement="bottom-end"
|
||||
isFiltersApplied={isIssueFilterActive(issueFilters)}
|
||||
>
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
|
||||
}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
<FiltersDropdown title={t("common.display")} placement="bottom-end">
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
import { ProjectDraftIssueHeader } from "./header";
|
||||
|
||||
export default function ProjectDraftIssuesLayou({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<ProjectDraftIssueHeader />} />
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { X, PenSquare } from "lucide-react";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { DraftIssueLayoutRoot } from "@/components/issues/issue-layouts/roots/draft-issue-layout-root";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
|
||||
const ProjectDraftIssuesPage = observer(() => {
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const project = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
const pageTitle = project?.name ? `${project?.name} - Draft work items` : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="gap-1 flex items-center border-b border-custom-border-200 px-4 py-2.5 shadow-sm bg-custom-background-100 z-[12]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/${workspaceSlug}/projects/${projectId}/issues/`)}
|
||||
className="flex items-center gap-1.5 rounded-full border border-custom-border-200 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<PenSquare className="h-4 w-4" />
|
||||
<span>Draft work items</span>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<DraftIssueLayoutRoot />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectDraftIssuesPage;
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
import { ProjectInboxHeader } from "@/plane-web/components/projects/settings/intake";
|
||||
|
||||
export default function ProjectInboxIssuesLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<ProjectInboxHeader />} />
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
"use client";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { EUserPermissionsLevel } from "@plane/constants";
|
||||
import { EUserProjectRoles } from "@plane/constants/src/user";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { DetailedEmptyState } from "@/components/empty-state";
|
||||
import { InboxIssueRoot } from "@/components/inbox";
|
||||
// helpers
|
||||
import { EInboxIssueCurrentTab } from "@/helpers/inbox.helper";
|
||||
// hooks
|
||||
import { useProject, useUserPermissions } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
|
||||
const ProjectInboxPage = observer(() => {
|
||||
/// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const navigationTab = searchParams.get("currentTab");
|
||||
const inboxIssueId = searchParams.get("inboxIssueId");
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// derived values
|
||||
const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT);
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/intake" });
|
||||
|
||||
// No access to inbox
|
||||
if (currentProjectDetails?.inbox_view === false)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<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}/projects/${projectId}/settings/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;
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="w-full h-full overflow-hidden">
|
||||
<InboxIssueRoot
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
inboxIssueId={inboxIssueId?.toString() || undefined}
|
||||
inboxAccessible={currentProjectDetails?.inbox_view || false}
|
||||
navigationTab={currentNavigationTab}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectInboxPage;
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { redirect, useParams } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import useSWR from "swr";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { EmptyState, LogoSpinner } from "@/components/common";
|
||||
// hooks
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// assets
|
||||
import emptyIssueDark from "@/public/empty-state/search/issues-dark.webp";
|
||||
import emptyIssueLight from "@/public/empty-state/search/issues-light.webp";
|
||||
// services
|
||||
import { IssueService } from "@/services/issue/issue.service";
|
||||
|
||||
const issueService = new IssueService();
|
||||
|
||||
const IssueDetailsPage = observer(() => {
|
||||
const router = useAppRouter();
|
||||
const { t } = useTranslation();
|
||||
const { workspaceSlug, projectId, issueId } = useParams();
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const { data, isLoading, error } = useSWR(
|
||||
workspaceSlug && projectId && issueId ? `ISSUE_DETAIL_META_${workspaceSlug}_${projectId}_${issueId}` : null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? () => issueService.getIssueMetaFromURL(workspaceSlug.toString(), projectId.toString(), issueId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
redirect(`/${workspaceSlug}/browse/${data.project_identifier}-${data.sequence_id}`);
|
||||
}
|
||||
}, [workspaceSlug, data]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center size-full">
|
||||
{error ? (
|
||||
<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(`/${workspaceSlug}/workspace-views/all-issues/`),
|
||||
}}
|
||||
/>
|
||||
) : isLoading ? (
|
||||
<>
|
||||
<LogoSpinner />
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default IssueDetailsPage;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import { IssuesHeader } from "@/plane-web/components/issues";
|
||||
|
||||
export const ProjectIssuesHeader = () => <IssuesHeader />;
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
import { ProjectIssuesHeader } from "./header";
|
||||
import { ProjectIssuesMobileHeader } from "./mobile-header";
|
||||
|
||||
export default function ProjectIssuesLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<ProjectIssuesHeader />} mobileHeader={<ProjectIssuesMobileHeader />} />
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// icons
|
||||
import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
|
||||
// plane constants
|
||||
import {
|
||||
EIssueLayoutTypes,
|
||||
EIssueFilterType,
|
||||
EIssuesStoreType,
|
||||
ISSUE_LAYOUTS,
|
||||
ISSUE_DISPLAY_FILTERS_BY_PAGE,
|
||||
} from "@plane/constants";
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
import { WorkItemsModal } from "@/components/analytics-v2/work-items/modal";
|
||||
import {
|
||||
DisplayFiltersSelection,
|
||||
FilterSelection,
|
||||
FiltersDropdown,
|
||||
IssueLayoutIcon,
|
||||
} from "@/components/issues/issue-layouts";
|
||||
// helpers
|
||||
import { isIssueFilterActive } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import { useIssues, useLabel, useMember, useProject, useProjectState } from "@/hooks/store";
|
||||
|
||||
export const ProjectIssuesMobileHeader = observer(() => {
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
const layouts = [
|
||||
{ key: "list", titleTranslationKey: "issue.layouts.list", icon: List },
|
||||
{ key: "kanban", titleTranslationKey: "issue.layouts.kanban", icon: Kanban },
|
||||
{ key: "calendar", titleTranslationKey: "issue.layouts.calendar", icon: Calendar },
|
||||
];
|
||||
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||
const { workspaceSlug, projectId } = useParams() as {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
};
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.PROJECT);
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
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 handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues });
|
||||
},
|
||||
[workspaceSlug, projectId, issueFilters, 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="md:hidden flex justify-evenly border-b border-custom-border-200 py-2 z-[13] bg-custom-background-100">
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className="flex flex-grow justify-center text-sm text-custom-text-200"
|
||||
placement="bottom-start"
|
||||
customButton={
|
||||
<div className="flex flex-start text-sm text-custom-text-200">
|
||||
{t("common.layout")}
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200 my-auto" strokeWidth={2} />
|
||||
</div>
|
||||
}
|
||||
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||
closeOnSelect
|
||||
>
|
||||
{layouts.map((layout, index) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={index}
|
||||
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-custom-text-300">{t(layout.titleTranslationKey)}</div>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<FiltersDropdown
|
||||
title={t("common.filters")}
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<span className="flex items-center text-sm text-custom-text-200">
|
||||
{t("common.filters")}
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" />
|
||||
</span>
|
||||
}
|
||||
isFiltersApplied={isIssueFilterActive(issueFilters)}
|
||||
>
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
|
||||
}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<FiltersDropdown
|
||||
title={t("common.display")}
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<span className="flex items-center text-sm text-custom-text-200">
|
||||
{t("common.display")}
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[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-custom-border-200 text-sm text-custom-text-200"
|
||||
>
|
||||
{t("common.analytics")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import Head from "next/head";
|
||||
import { useParams } from "next/navigation";
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProjectLayoutRoot } from "@/components/issues";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
|
||||
const ProjectIssuesPage = observer(() => {
|
||||
const { projectId } = useParams();
|
||||
// i18n
|
||||
const { t } = useTranslation();
|
||||
// store
|
||||
const { getProjectById } = useProject();
|
||||
|
||||
if (!projectId) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
// derived values
|
||||
const project = getProjectById(projectId.toString());
|
||||
const pageTitle = project?.name ? `${project?.name} - ${t("issue.label", { count: 2 })}` : undefined; // Count is for pluralization
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<Head>
|
||||
<title>
|
||||
{project?.name} - {t("issue.label", { count: 2 })}
|
||||
</title>
|
||||
</Head>
|
||||
<div className="h-full w-full">
|
||||
<ProjectLayoutRoot />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectIssuesPage;
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { EmptyState } from "@/components/common";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ModuleLayoutRoot } from "@/components/issues";
|
||||
import { ModuleAnalyticsSidebar } from "@/components/modules";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useModule, useProject } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
// assets
|
||||
import emptyModule from "@/public/empty-state/module.svg";
|
||||
|
||||
const ModuleIssuesPage = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, projectId, moduleId } = useParams();
|
||||
// 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(
|
||||
workspaceSlug && projectId && moduleId ? `CURRENT_MODULE_DETAILS_${moduleId.toString()}` : null,
|
||||
workspaceSlug && projectId && moduleId
|
||||
? () => fetchModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString())
|
||||
: null
|
||||
);
|
||||
// derived values
|
||||
const projectModule = moduleId ? getModuleById(moduleId.toString()) : undefined;
|
||||
const project = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
const pageTitle = project?.name && projectModule?.name ? `${project?.name} - ${projectModule?.name}` : undefined;
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setValue(`${!isSidebarCollapsed}`);
|
||||
};
|
||||
|
||||
if (!workspaceSlug || !projectId || !moduleId) return <></>;
|
||||
|
||||
// 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>
|
||||
{moduleId && !isSidebarCollapsed && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 duration-300 vertical-scrollbar scrollbar-sm absolute right-0 z-[13]"
|
||||
)}
|
||||
style={{
|
||||
boxShadow:
|
||||
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",
|
||||
}}
|
||||
>
|
||||
<ModuleAnalyticsSidebar moduleId={moduleId.toString()} handleClose={toggleSidebar} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ModuleIssuesPage;
|
||||
|
|
@ -0,0 +1,316 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
// icons
|
||||
import { PanelRight } from "lucide-react";
|
||||
// plane constants
|
||||
import {
|
||||
EIssueLayoutTypes,
|
||||
EIssuesStoreType,
|
||||
EIssueFilterType,
|
||||
ISSUE_DISPLAY_FILTERS_BY_PAGE,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
} from "@plane/constants";
|
||||
// types
|
||||
import {
|
||||
ICustomSearchSelectOption,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
} from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, DiceIcon, Tooltip, Header, CustomSearchSelect } from "@plane/ui";
|
||||
// components
|
||||
import { WorkItemsModal } from "@/components/analytics-v2/work-items/modal";
|
||||
import { BreadcrumbLink, SwitcherLabel } from "@/components/common";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
|
||||
// helpers
|
||||
import { ModuleQuickActions } from "@/components/modules";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { isIssueFilterActive } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import {
|
||||
useEventTracker,
|
||||
useLabel,
|
||||
useMember,
|
||||
useModule,
|
||||
useProject,
|
||||
useProjectState,
|
||||
useIssues,
|
||||
useCommandPalette,
|
||||
useUserPermissions,
|
||||
} from "@/hooks/store";
|
||||
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
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
|
||||
export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
// refs
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
// states
|
||||
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, projectId, moduleId } = useParams();
|
||||
// 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 { setTrackElement } = useEventTracker();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { currentProjectDetails, loader } = useProject();
|
||||
const { projectLabels } = useLabel();
|
||||
const { projectStates } = useProjectState();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
|
||||
const { setValue, storedValue } = useLocalStorage("module_sidebar_collapsed", "false");
|
||||
|
||||
const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false;
|
||||
const toggleSidebar = () => {
|
||||
setValue(`${!isSidebarCollapsed}`);
|
||||
};
|
||||
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: EIssueLayoutTypes) => {
|
||||
if (!projectId) return;
|
||||
updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_FILTERS, { layout: layout });
|
||||
},
|
||||
[projectId, updateFilters]
|
||||
);
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!projectId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(projectId.toString(), EIssueFilterType.FILTERS, { [key]: newValues });
|
||||
},
|
||||
[projectId, issueFilters, 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]
|
||||
);
|
||||
|
||||
// derived values
|
||||
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
|
||||
const canUserCreateIssue = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT
|
||||
);
|
||||
|
||||
const workItemsCount = getGroupIssueCount(undefined, undefined, false);
|
||||
|
||||
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={DiceIcon} />,
|
||||
};
|
||||
})
|
||||
.filter((option) => option !== undefined) as ICustomSearchSelectOption[];
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkItemsModal
|
||||
isOpen={analyticsModal}
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
moduleDetails={moduleDetails ?? undefined}
|
||||
projectDetails={currentProjectDetails}
|
||||
/>
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<span>
|
||||
<span className="hidden md:block">
|
||||
<ProjectBreadcrumb />
|
||||
</span>
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
|
||||
className="block pl-2 text-custom-text-300 md:hidden"
|
||||
>
|
||||
...
|
||||
</Link>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/modules`}
|
||||
label="Modules"
|
||||
icon={<DiceIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="component"
|
||||
component={
|
||||
<CustomSearchSelect
|
||||
options={switcherOptions}
|
||||
label={
|
||||
<div className="flex items-center gap-1">
|
||||
<SwitcherLabel name={moduleDetails?.name} LabelIcon={DiceIcon} />
|
||||
{workItemsCount && workItemsCount > 0 ? (
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={`There are ${workItemsCount} ${
|
||||
workItemsCount > 1 ? "work items" : "work item"
|
||||
} in this module`}
|
||||
position="bottom"
|
||||
>
|
||||
<span className="flex flex-shrink-0 cursor-default items-center justify-center rounded-xl bg-custom-primary-100/20 px-2 text-center text-xs font-semibold text-custom-primary-100">
|
||||
{workItemsCount}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
value={moduleId}
|
||||
onChange={(value: string) => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/modules/${value}`);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem className="items-center">
|
||||
<div className="hidden gap-2 md:flex">
|
||||
<LayoutSelection
|
||||
layouts={[
|
||||
EIssueLayoutTypes.LIST,
|
||||
EIssueLayoutTypes.KANBAN,
|
||||
EIssueLayoutTypes.CALENDAR,
|
||||
EIssueLayoutTypes.SPREADSHEET,
|
||||
EIssueLayoutTypes.GANTT,
|
||||
]}
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
<FiltersDropdown
|
||||
title="Filters"
|
||||
placement="bottom-end"
|
||||
isFiltersApplied={isIssueFilterActive(issueFilters)}
|
||||
>
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
|
||||
}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
<FiltersDropdown title="Display" placement="bottom-end">
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[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="neutral-primary"
|
||||
size="sm"
|
||||
>
|
||||
Analytics
|
||||
</Button>
|
||||
<Button
|
||||
className="hidden sm:flex"
|
||||
onClick={() => {
|
||||
setTrackElement("Module work items page");
|
||||
toggleCreateIssueModal(true, EIssuesStoreType.MODULE);
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
Add work item
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded outline-none hover:bg-custom-sidebar-background-80 bg-custom-background-80/70"
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<PanelRight className={cn("h-4 w-4", !isSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200")} />
|
||||
</button>
|
||||
<ModuleQuickActions
|
||||
parentRef={parentRef}
|
||||
moduleId={moduleId?.toString()}
|
||||
projectId={projectId.toString()}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
customClassName="flex-shrink-0 flex items-center justify-center bg-custom-background-80/70 rounded size-[26px]"
|
||||
/>
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
import { ModuleIssuesHeader } from "./header";
|
||||
import { ModuleIssuesMobileHeader } from "./mobile-header";
|
||||
|
||||
export default function ProjectModuleIssuesLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<ModuleIssuesHeader />} mobileHeader={<ModuleIssuesMobileHeader />} />
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// icons
|
||||
import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
|
||||
// plane constants
|
||||
import {
|
||||
EIssueLayoutTypes,
|
||||
EIssueFilterType,
|
||||
EIssuesStoreType,
|
||||
ISSUE_LAYOUTS,
|
||||
ISSUE_DISPLAY_FILTERS_BY_PAGE,
|
||||
} from "@plane/constants";
|
||||
// plane i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// types
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
import { ProjectAnalyticsModal } from "@/components/analytics";
|
||||
import { WorkItemsModal } from "@/components/analytics-v2/work-items/modal";
|
||||
import {
|
||||
DisplayFiltersSelection,
|
||||
FilterSelection,
|
||||
FiltersDropdown,
|
||||
IssueLayoutIcon,
|
||||
} from "@/components/issues/issue-layouts";
|
||||
// helpers
|
||||
import { isIssueFilterActive } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import { useIssues, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store";
|
||||
|
||||
export const ModuleIssuesMobileHeader = observer(() => {
|
||||
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { getModuleById } = useModule();
|
||||
const { t } = useTranslation();
|
||||
const layouts = [
|
||||
{ key: "list", i18n_title: "issue.layouts.list", icon: List },
|
||||
{ key: "kanban", i18n_title: "issue.layouts.kanban", icon: Kanban },
|
||||
{ key: "calendar", i18n_title: "issue.layouts.calendar", icon: Calendar },
|
||||
];
|
||||
const { workspaceSlug, projectId, moduleId } = useParams() as {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
moduleId: string;
|
||||
};
|
||||
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
|
||||
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.MODULE);
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: EIssueLayoutTypes) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, moduleId);
|
||||
},
|
||||
[workspaceSlug, projectId, moduleId, updateFilters]
|
||||
);
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }, moduleId);
|
||||
},
|
||||
[workspaceSlug, projectId, moduleId, issueFilters, 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-custom-border-200 bg-custom-background-100 py-2">
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className="flex flex-grow justify-center text-sm text-custom-text-200"
|
||||
placement="bottom-start"
|
||||
customButton={<span className="flex flex-grow justify-center text-sm text-custom-text-200">Layout</span>}
|
||||
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||
closeOnSelect
|
||||
>
|
||||
{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-custom-text-300">{t(layout.i18n_title)}</div>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<FiltersDropdown
|
||||
title="Filters"
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<span className="flex items-center text-sm text-custom-text-200">
|
||||
Filters
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" />
|
||||
</span>
|
||||
}
|
||||
isFiltersApplied={isIssueFilterActive(issueFilters)}
|
||||
>
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
|
||||
}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<FiltersDropdown
|
||||
title="Display"
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<span className="flex items-center text-sm text-custom-text-200">
|
||||
Display
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[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-custom-border-200 text-sm text-custom-text-200"
|
||||
>
|
||||
Analytics
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, DiceIcon, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
import { ModuleViewHeader } from "@/components/modules";
|
||||
// hooks
|
||||
import { useCommandPalette, useEventTracker, useProject, useUserPermissions } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane web
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
// constants
|
||||
|
||||
export const ModulesListHeader: React.FC = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
const { toggleCreateModuleModal } = useCommandPalette();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
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"}>
|
||||
<ProjectBreadcrumb />
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink label={t("modules")} icon={<DiceIcon className="h-4 w-4 text-custom-text-300" />} />
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem>
|
||||
<ModuleViewHeader />
|
||||
{canUserCreateModule ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTrackElement("Modules page");
|
||||
toggleCreateModuleModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="sm:hidden block">{t("add")}</div>
|
||||
<div className="hidden sm:block">{t("project_module.add_module")}</div>
|
||||
</Button>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
import { ModulesListHeader } from "./header";
|
||||
import { ModulesListMobileHeader } from "./mobile-header";
|
||||
|
||||
export default function ProjectModulesListLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<ModulesListHeader />} mobileHeader={<ModulesListMobileHeader />} />
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { MODULE_VIEW_LAYOUTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { CustomMenu, Row } from "@plane/ui";
|
||||
import { ModuleLayoutIcon } from "@/components/modules";
|
||||
import { useModuleFilter, useProject } from "@/hooks/store";
|
||||
|
||||
export const ModulesListMobileHeader = observer(() => {
|
||||
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 text-custom-text-200 text-sm py-2 border-b border-custom-border-200 bg-custom-sidebar-background-100"
|
||||
// placement="bottom-start"
|
||||
customButton={
|
||||
<Row className="flex flex-grow justify-center text-custom-text-200 text-sm gap-2">
|
||||
<span>Layout</span> <ChevronDown className="h-4 w-4 text-custom-text-200 my-auto" strokeWidth={1} />
|
||||
</Row>
|
||||
}
|
||||
customButtonClassName="flex flex-grow justify-center items-center text-custom-text-200 text-sm"
|
||||
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-custom-text-300">{t(layout.i18n_title)}</div>
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// types
|
||||
import { EUserPermissionsLevel, EUserProjectRoles } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TModuleFilters } from "@plane/types";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { DetailedEmptyState } from "@/components/empty-state";
|
||||
import { ModuleAppliedFiltersList, ModulesListView } from "@/components/modules";
|
||||
// helpers
|
||||
import { calculateTotalFilters } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import { useModuleFilter, useProject, useUserPermissions } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
|
||||
const ProjectModulesPage = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store
|
||||
const { getProjectById, currentProjectDetails } = useProject();
|
||||
const { currentProjectFilters, currentProjectDisplayFilters, clearAllFilters, updateFilters, updateDisplayFilters } =
|
||||
useModuleFilter();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// derived values
|
||||
const project = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
const pageTitle = project?.name ? `${project?.name} - Modules` : undefined;
|
||||
const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT);
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/modules" });
|
||||
|
||||
const handleRemoveFilter = useCallback(
|
||||
(key: keyof TModuleFilters, value: string | null) => {
|
||||
if (!projectId) return;
|
||||
let newValues = currentProjectFilters?.[key] ?? [];
|
||||
|
||||
if (!value) newValues = [];
|
||||
else newValues = newValues.filter((val) => val !== value);
|
||||
|
||||
updateFilters(projectId.toString(), { [key]: newValues });
|
||||
},
|
||||
[currentProjectFilters, projectId, updateFilters]
|
||||
);
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
|
||||
// No access to
|
||||
if (currentProjectDetails?.module_view === false)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<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}/projects/${projectId}/settings/features`);
|
||||
},
|
||||
disabled: !canPerformEmptyStateActions,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="h-full w-full flex flex-col">
|
||||
{(calculateTotalFilters(currentProjectFilters ?? {}) !== 0 || currentProjectDisplayFilters?.favorites) && (
|
||||
<ModuleAppliedFiltersList
|
||||
appliedFilters={currentProjectFilters ?? {}}
|
||||
isFavoriteFilterApplied={currentProjectDisplayFilters?.favorites ?? false}
|
||||
handleClearAllFilters={() => clearAllFilters(`${projectId}`)}
|
||||
handleRemoveFilter={handleRemoveFilter}
|
||||
handleDisplayFiltersUpdate={(val) => {
|
||||
if (!projectId) return;
|
||||
updateDisplayFilters(projectId.toString(), val);
|
||||
}}
|
||||
alwaysAllowEditing
|
||||
/>
|
||||
)}
|
||||
<ModulesListView />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectModulesPage;
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane types
|
||||
import { TSearchEntityRequestPayload, TWebhookConnectionQueryParams } from "@plane/types";
|
||||
import { EFileAssetType } from "@plane/types/src/enums";
|
||||
// plane ui
|
||||
import { getButtonStyling } from "@plane/ui";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { IssuePeekOverview } from "@/components/issues";
|
||||
import { PageRoot, TPageRootConfig, TPageRootHandlers } from "@/components/pages";
|
||||
// hooks
|
||||
import { useEditorConfig } from "@/hooks/editor";
|
||||
import { useEditorAsset, useWorkspace } from "@/hooks/store";
|
||||
// plane web hooks
|
||||
import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store";
|
||||
// plane web services
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
// services
|
||||
import { ProjectPageService, ProjectPageVersionService } from "@/services/page";
|
||||
const workspaceService = new WorkspaceService();
|
||||
const projectPageService = new ProjectPageService();
|
||||
const projectPageVersionService = new ProjectPageVersionService();
|
||||
|
||||
const PageDetailsPage = observer(() => {
|
||||
const { workspaceSlug, projectId, pageId } = useParams();
|
||||
// store hooks
|
||||
const { createPage, fetchPageDetails } = usePageStore(EPageStoreType.PROJECT);
|
||||
const page = usePage({
|
||||
pageId: pageId?.toString() ?? "",
|
||||
storeType: EPageStoreType.PROJECT,
|
||||
});
|
||||
const { getWorkspaceBySlug } = useWorkspace();
|
||||
const { uploadEditorAsset } = useEditorAsset();
|
||||
// derived values
|
||||
const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : "";
|
||||
const { canCurrentUserAccessPage, id, name, updateDescription } = page ?? {};
|
||||
// entity search handler
|
||||
const fetchEntityCallback = useCallback(
|
||||
async (payload: TSearchEntityRequestPayload) =>
|
||||
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
|
||||
...payload,
|
||||
project_id: projectId?.toString() ?? "",
|
||||
}),
|
||||
[projectId, workspaceSlug]
|
||||
);
|
||||
// editor config
|
||||
const { getEditorFileHandlers } = useEditorConfig();
|
||||
// fetch page details
|
||||
const { error: pageDetailsError } = useSWR(
|
||||
workspaceSlug && projectId && pageId ? `PAGE_DETAILS_${pageId}` : null,
|
||||
workspaceSlug && projectId && pageId
|
||||
? () => fetchPageDetails(workspaceSlug?.toString(), projectId?.toString(), pageId.toString())
|
||||
: null,
|
||||
{
|
||||
revalidateIfStale: true,
|
||||
revalidateOnFocus: true,
|
||||
revalidateOnReconnect: true,
|
||||
}
|
||||
);
|
||||
// page root handlers
|
||||
const pageRootHandlers: TPageRootHandlers = useMemo(
|
||||
() => ({
|
||||
create: createPage,
|
||||
fetchAllVersions: async (pageId) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
return await projectPageVersionService.fetchAllVersions(workspaceSlug.toString(), projectId.toString(), pageId);
|
||||
},
|
||||
fetchDescriptionBinary: async () => {
|
||||
if (!workspaceSlug || !projectId || !id) return;
|
||||
return await projectPageService.fetchDescriptionBinary(workspaceSlug.toString(), projectId.toString(), id);
|
||||
},
|
||||
fetchEntity: fetchEntityCallback,
|
||||
fetchVersionDetails: async (pageId, versionId) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
return await projectPageVersionService.fetchVersionById(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
pageId,
|
||||
versionId
|
||||
);
|
||||
},
|
||||
getRedirectionLink: (pageId) => `/${workspaceSlug}/projects/${projectId}/pages/${pageId}`,
|
||||
updateDescription: updateDescription ?? (async () => {}),
|
||||
}),
|
||||
[createPage, fetchEntityCallback, id, projectId, updateDescription, workspaceSlug]
|
||||
);
|
||||
// page root config
|
||||
const pageRootConfig: TPageRootConfig = useMemo(
|
||||
() => ({
|
||||
fileHandler: getEditorFileHandlers({
|
||||
projectId: projectId?.toString() ?? "",
|
||||
uploadFile: async (blockId, file) => {
|
||||
const { asset_id } = await uploadEditorAsset({
|
||||
blockId,
|
||||
data: {
|
||||
entity_identifier: id ?? "",
|
||||
entity_type: EFileAssetType.PAGE_DESCRIPTION,
|
||||
},
|
||||
file,
|
||||
projectId: projectId?.toString() ?? "",
|
||||
workspaceSlug: workspaceSlug?.toString() ?? "",
|
||||
});
|
||||
return asset_id;
|
||||
},
|
||||
workspaceId,
|
||||
workspaceSlug: workspaceSlug?.toString() ?? "",
|
||||
}),
|
||||
}),
|
||||
[getEditorFileHandlers, id, projectId, uploadEditorAsset, workspaceId, workspaceSlug]
|
||||
);
|
||||
|
||||
const webhookConnectionParams: TWebhookConnectionQueryParams = useMemo(
|
||||
() => ({
|
||||
documentType: "project_page",
|
||||
projectId: projectId?.toString() ?? "",
|
||||
workspaceSlug: workspaceSlug?.toString() ?? "",
|
||||
}),
|
||||
[projectId, workspaceSlug]
|
||||
);
|
||||
|
||||
if ((!page || !id) && !pageDetailsError)
|
||||
return (
|
||||
<div className="size-full grid place-items-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (pageDetailsError || !canCurrentUserAccessPage)
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center">
|
||||
<h3 className="text-lg font-semibold text-center">Page not found</h3>
|
||||
<p className="text-sm text-custom-text-200 text-center mt-3">
|
||||
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("neutral-primary", "md"), "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 h-full w-full flex-shrink-0 flex flex-col overflow-hidden">
|
||||
<PageRoot
|
||||
config={pageRootConfig}
|
||||
handlers={pageRootHandlers}
|
||||
page={page}
|
||||
webhookConnectionParams={webhookConnectionParams}
|
||||
workspaceSlug={workspaceSlug?.toString() ?? ""}
|
||||
/>
|
||||
<IssuePeekOverview />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default PageDetailsPage;
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
"use client";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { FileText } from "lucide-react";
|
||||
// types
|
||||
import { ICustomSearchSelectOption } from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, Header, CustomSearchSelect } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink, PageAccessIcon, SwitcherLabel } from "@/components/common";
|
||||
import { PageHeaderActions } from "@/components/pages/header/actions";
|
||||
// helpers
|
||||
import { getPageName } from "@/helpers/page.helper";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
// plane web components
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
import { PageDetailsHeaderExtraActions } from "@/plane-web/components/pages";
|
||||
// plane web hooks
|
||||
import { EPageStoreType, usePage, usePageStore } from "@/plane-web/hooks/store";
|
||||
|
||||
export interface IPagesHeaderProps {
|
||||
showButton?: boolean;
|
||||
}
|
||||
|
||||
const storeType = EPageStoreType.PROJECT;
|
||||
|
||||
export const PageDetailsHeader = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, pageId, projectId } = useParams();
|
||||
// store hooks
|
||||
const { currentProjectDetails, 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 gap-2 items-center justify-between">
|
||||
<SwitcherLabel logo_props={_page.logo_props} name={getPageName(_page.name)} LabelIcon={FileText} />
|
||||
<PageAccessIcon {..._page} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((option) => option !== undefined) as ICustomSearchSelectOption[];
|
||||
|
||||
if (!page) return null;
|
||||
|
||||
return (
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<div>
|
||||
<Breadcrumbs isLoading={loader === "init-loader"}>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<span>
|
||||
<span className="hidden md:block">
|
||||
<ProjectBreadcrumb />
|
||||
</span>
|
||||
<span className="md:hidden">
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
|
||||
label={"..."}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/pages`}
|
||||
label="Pages"
|
||||
icon={<FileText className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="component"
|
||||
component={
|
||||
<CustomSearchSelect
|
||||
value={pageId}
|
||||
options={switcherOptions}
|
||||
label={
|
||||
<SwitcherLabel logo_props={page.logo_props} name={getPageName(page.name)} LabelIcon={FileText} />
|
||||
}
|
||||
onChange={(value: string) => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/pages/${value}`);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem>
|
||||
<PageDetailsHeaderExtraActions page={page} />
|
||||
<PageHeaderActions page={page} storeType={storeType} />
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
"use client";
|
||||
|
||||
// component
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
// plane web hooks
|
||||
import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store";
|
||||
// local components
|
||||
import { PageDetailsHeader } from "./header";
|
||||
|
||||
export default function ProjectPageDetailsLayout({ children }: { children: React.ReactNode }) {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const { fetchPagesList } = usePageStore(EPageStoreType.PROJECT);
|
||||
// fetching pages list
|
||||
useSWR(
|
||||
workspaceSlug && projectId ? `PROJECT_PAGES_${projectId}` : null,
|
||||
workspaceSlug && projectId ? () => fetchPagesList(workspaceSlug.toString(), projectId.toString()) : null
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<PageDetailsHeader />} />
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { FileText } from "lucide-react";
|
||||
// constants
|
||||
import { EPageAccess } from "@plane/constants";
|
||||
// plane types
|
||||
import { TPage } from "@plane/types";
|
||||
// plane ui
|
||||
import { Breadcrumbs, Button, Header, setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// helpers
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
// hooks
|
||||
import { useEventTracker, useProject } from "@/hooks/store";
|
||||
// plane web
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
// plane web hooks
|
||||
import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store";
|
||||
|
||||
export const PagesListHeader = observer(() => {
|
||||
// states
|
||||
const [isCreatingPage, setIsCreatingPage] = useState(false);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const pageType = searchParams.get("type");
|
||||
// store hooks
|
||||
const { currentProjectDetails, loader } = useProject();
|
||||
const { canCurrentUserCreatePage, createPage } = usePageStore(EPageStoreType.PROJECT);
|
||||
const { setTrackElement } = useEventTracker();
|
||||
// handle page create
|
||||
const handleCreatePage = async () => {
|
||||
setIsCreatingPage(true);
|
||||
setTrackElement("Project pages page");
|
||||
|
||||
const payload: Partial<TPage> = {
|
||||
access: pageType === "private" ? EPageAccess.PRIVATE : EPageAccess.PUBLIC,
|
||||
};
|
||||
|
||||
await createPage(payload)
|
||||
.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>
|
||||
<div>
|
||||
<Breadcrumbs isLoading={loader === "init-loader"}>
|
||||
<ProjectBreadcrumb />
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={<BreadcrumbLink label="Pages" icon={<FileText className="h-4 w-4 text-custom-text-300" />} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</Header.LeftItem>
|
||||
{canCurrentUserCreatePage ? (
|
||||
<Header.RightItem>
|
||||
<Button variant="primary" size="sm" onClick={handleCreatePage} loading={isCreatingPage}>
|
||||
{isCreatingPage ? "Adding" : "Add page"}
|
||||
</Button>
|
||||
</Header.RightItem>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
// components
|
||||
import { ContentWrapper, AppHeader } from "@/components/core";
|
||||
// local components
|
||||
import { PagesListHeader } from "./header";
|
||||
|
||||
export default function ProjectPagesListLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<PagesListHeader />} />
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
// plane imports
|
||||
import { EUserPermissionsLevel, EUserProjectRoles } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TPageNavigationTabs } from "@plane/types";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { DetailedEmptyState } from "@/components/empty-state";
|
||||
import { PagesListRoot, PagesListView } from "@/components/pages";
|
||||
// hooks
|
||||
import { useProject, useUserPermissions } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
// plane web hooks
|
||||
import { EPageStoreType } from "@/plane-web/hooks/store";
|
||||
|
||||
const ProjectPagesPage = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const type = searchParams.get("type");
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { getProjectById, currentProjectDetails } = useProject();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// derived values
|
||||
const project = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
const pageTitle = project?.name ? `${project?.name} - Pages` : undefined;
|
||||
const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT);
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/pages" });
|
||||
|
||||
const currentPageType = (): TPageNavigationTabs => {
|
||||
const pageType = type?.toString();
|
||||
if (pageType === "private") return "private";
|
||||
if (pageType === "archived") return "archived";
|
||||
return "public";
|
||||
};
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
|
||||
// No access to cycle
|
||||
if (currentProjectDetails?.page_view === false)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<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}/projects/${projectId}/settings/features`);
|
||||
},
|
||||
disabled: !canPerformEmptyStateActions,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<PagesListView
|
||||
pageType={currentPageType()}
|
||||
projectId={projectId.toString()}
|
||||
storeType={EPageStoreType.PROJECT}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
>
|
||||
<PagesListRoot pageType={currentPageType()} storeType={EPageStoreType.PROJECT} />
|
||||
</PagesListView>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectPagesPage;
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IProject } from "@plane/types";
|
||||
// ui
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation";
|
||||
import { PageHead } from "@/components/core";
|
||||
// hooks
|
||||
import { useProject, useUserPermissions } from "@/hooks/store";
|
||||
|
||||
const AutomationSettingsPage = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
const { currentProjectDetails: projectDetails, updateProject } = useProject();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// derived values
|
||||
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
|
||||
|
||||
const handleChange = async (formData: Partial<IProject>) => {
|
||||
if (!workspaceSlug || !projectId || !projectDetails) return;
|
||||
|
||||
await updateProject(workspaceSlug.toString(), projectId.toString(), formData).catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Something went wrong. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// derived values
|
||||
const pageTitle = projectDetails?.name ? `${projectDetails?.name} - Automations` : undefined;
|
||||
|
||||
if (workspaceUserInfo && !canPerformProjectAdminActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className={`w-full overflow-y-auto ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
|
||||
<div className="flex flex-col items-start border-b border-custom-border-100 pb-3.5">
|
||||
<h3 className="text-xl font-medium leading-normal">{t("project_settings.automations.label")}</h3>
|
||||
</div>
|
||||
<AutoArchiveAutomation handleChange={handleChange} />
|
||||
<AutoCloseAutomation handleChange={handleChange} />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default AutomationSettingsPage;
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// components
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { EstimateRoot } from "@/components/estimates";
|
||||
// hooks
|
||||
import { useProject, useUserPermissions } from "@/hooks/store";
|
||||
|
||||
const EstimatesSettingsPage = observer(() => {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
|
||||
// derived values
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Estimates` : undefined;
|
||||
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
|
||||
if (workspaceUserInfo && !canPerformProjectAdminActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div
|
||||
className={`w-full overflow-y-auto ${canPerformProjectAdminActions ? "" : "pointer-events-none opacity-60"}`}
|
||||
>
|
||||
<EstimateRoot
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={projectId?.toString()}
|
||||
isAdmin={canPerformProjectAdminActions}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default EstimatesSettingsPage;
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// components
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProjectFeaturesList } from "@/components/project";
|
||||
// hooks
|
||||
import { useProject, useUserPermissions } from "@/hooks/store";
|
||||
|
||||
const FeaturesSettingsPage = observer(() => {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
|
||||
const { currentProjectDetails } = useProject();
|
||||
// derived values
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Features` : undefined;
|
||||
const canPerformProjectAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT);
|
||||
|
||||
if (!workspaceSlug || !projectId) return null;
|
||||
|
||||
if (workspaceUserInfo && !canPerformProjectAdminActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className={`w-full overflow-y-auto ${canPerformProjectAdminActions ? "" : "opacity-60"}`}>
|
||||
<ProjectFeaturesList
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
isAdmin={canPerformProjectAdminActions}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default FeaturesSettingsPage;
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProjectSettingsLabelList } from "@/components/labels";
|
||||
// hooks
|
||||
import { useProject, useUserPermissions } from "@/hooks/store";
|
||||
|
||||
const LabelsSettingsPage = observer(() => {
|
||||
// store hooks
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Labels` : undefined;
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// derived values
|
||||
const canPerformProjectMemberActions = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT
|
||||
);
|
||||
|
||||
// Enable Auto Scroll for Labels list
|
||||
useEffect(() => {
|
||||
const element = scrollableContainerRef.current;
|
||||
|
||||
if (!element) return;
|
||||
|
||||
return combine(
|
||||
autoScrollForElements({
|
||||
element,
|
||||
})
|
||||
);
|
||||
}, [scrollableContainerRef?.current]);
|
||||
|
||||
if (workspaceUserInfo && !canPerformProjectMemberActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div ref={scrollableContainerRef} className="h-full w-full gap-10 overflow-y-auto">
|
||||
<ProjectSettingsLabelList />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default LabelsSettingsPage;
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import { FC, ReactNode } from "react";
|
||||
// components
|
||||
import { AppHeader } from "@/components/core";
|
||||
// local components
|
||||
import { ProjectSettingHeader } from "../header";
|
||||
import { ProjectSettingsSidebar } from "./sidebar";
|
||||
|
||||
export interface IProjectSettingLayout {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const ProjectSettingLayout: FC<IProjectSettingLayout> = (props) => {
|
||||
const { children } = props;
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<ProjectSettingHeader />} />
|
||||
<div className="inset-y-0 flex flex-row vertical-scrollbar scrollbar-lg h-full w-full overflow-y-auto">
|
||||
<div className="px-page-x !pr-0 py-page-y flex-shrink-0 overflow-y-hidden sm:hidden hidden md:block lg:block">
|
||||
<ProjectSettingsSidebar />
|
||||
</div>
|
||||
<div className="flex flex-col relative w-full overflow-hidden">
|
||||
<div className="h-full w-full overflow-x-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-page-x md:px-9 py-page-y">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectSettingLayout;
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProjectMemberList, ProjectSettingsMemberDefaults } from "@/components/project";
|
||||
// hooks
|
||||
import { useProject, useUserPermissions } from "@/hooks/store";
|
||||
|
||||
const MembersSettingsPage = observer(() => {
|
||||
// store
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
// derived values
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined;
|
||||
const isProjectMemberOrAdmin = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT
|
||||
);
|
||||
const isWorkspaceAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
const canPerformProjectMemberActions = isProjectMemberOrAdmin || isWorkspaceAdmin;
|
||||
|
||||
if (workspaceUserInfo && !canPerformProjectMemberActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className={`w-full overflow-y-auto`}>
|
||||
<ProjectSettingsMemberDefaults />
|
||||
<ProjectMemberList />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default MembersSettingsPage;
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { PageHead } from "@/components/core";
|
||||
import {
|
||||
ArchiveRestoreProjectModal,
|
||||
ArchiveProjectSelection,
|
||||
DeleteProjectModal,
|
||||
DeleteProjectSection,
|
||||
ProjectDetailsForm,
|
||||
ProjectDetailsFormLoader,
|
||||
} from "@/components/project";
|
||||
// hooks
|
||||
import { useProject, useUserPermissions } from "@/hooks/store";
|
||||
|
||||
const GeneralSettingsPage = observer(() => {
|
||||
// states
|
||||
const [selectProject, setSelectedProject] = useState<string | null>(null);
|
||||
const [archiveProject, setArchiveProject] = useState<boolean>(false);
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const { currentProjectDetails, fetchProjectDetails } = useProject();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
// api call to fetch project details
|
||||
// TODO: removed this API if not necessary
|
||||
const { isLoading } = useSWR(
|
||||
workspaceSlug && projectId ? `PROJECT_DETAILS_${projectId}` : null,
|
||||
workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug.toString(), projectId.toString()) : null
|
||||
);
|
||||
// derived values
|
||||
const isAdmin = allowPermissions(
|
||||
[EUserPermissions.ADMIN],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString()
|
||||
);
|
||||
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - General Settings` : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
{currentProjectDetails && workspaceSlug && projectId && (
|
||||
<>
|
||||
<ArchiveRestoreProjectModal
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
isOpen={archiveProject}
|
||||
onClose={() => setArchiveProject(false)}
|
||||
archive
|
||||
/>
|
||||
<DeleteProjectModal
|
||||
project={currentProjectDetails}
|
||||
isOpen={Boolean(selectProject)}
|
||||
onClose={() => setSelectedProject(null)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={`w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}>
|
||||
{currentProjectDetails && workspaceSlug && projectId && !isLoading ? (
|
||||
<ProjectDetailsForm
|
||||
project={currentProjectDetails}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
) : (
|
||||
<ProjectDetailsFormLoader />
|
||||
)}
|
||||
|
||||
{isAdmin && currentProjectDetails && (
|
||||
<>
|
||||
<ArchiveProjectSelection
|
||||
projectDetails={currentProjectDetails}
|
||||
handleArchive={() => setArchiveProject(true)}
|
||||
/>
|
||||
<DeleteProjectSection
|
||||
projectDetails={currentProjectDetails}
|
||||
handleDelete={() => setSelectedProject(currentProjectDetails.id ?? null)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default GeneralSettingsPage;
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import range from "lodash/range";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { SidebarNavItem } from "@/components/sidebar";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store";
|
||||
// plane web constants
|
||||
import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project";
|
||||
|
||||
export const ProjectSettingsSidebar = observer(() => {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const pathname = usePathname();
|
||||
// mobx store
|
||||
const { allowPermissions, projectUserInfo } = useUserPermissions();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// derived values
|
||||
const currentProjectRole = projectUserInfo?.[workspaceSlug?.toString()]?.[projectId?.toString()]?.role;
|
||||
|
||||
if (!currentProjectRole) {
|
||||
return (
|
||||
<div className="flex w-[280px] flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-semibold text-custom-sidebar-text-400">SETTINGS</span>
|
||||
<Loader className="flex w-full flex-col gap-2">
|
||||
{range(8).map((index) => (
|
||||
<Loader.Item key={index} height="34px" />
|
||||
))}
|
||||
</Loader>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-[280px] flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-semibold text-custom-sidebar-text-400">SETTINGS</span>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{PROJECT_SETTINGS_LINKS.map(
|
||||
(link) =>
|
||||
allowPermissions(
|
||||
link.access,
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString()
|
||||
) && (
|
||||
<Link key={link.key} href={`/${workspaceSlug}/projects/${projectId}${link.href}`}>
|
||||
<SidebarNavItem
|
||||
key={link.key}
|
||||
isActive={link.highlight(pathname, `/${workspaceSlug}/projects/${projectId}`)}
|
||||
className="text-sm font-medium px-4 py-2"
|
||||
>
|
||||
{t(link.i18n_label)}
|
||||
</SidebarNavItem>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProjectStateRoot } from "@/components/project-states";
|
||||
// hook
|
||||
import { useProject, useUserPermissions } from "@/hooks/store";
|
||||
|
||||
const StatesSettingsPage = observer(() => {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// derived values
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - States` : undefined;
|
||||
// derived values
|
||||
const canPerformProjectMemberActions = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT
|
||||
);
|
||||
|
||||
if (workspaceUserInfo && !canPerformProjectMemberActions) {
|
||||
return <NotAuthorizedView section="settings" isProjectView />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="flex items-center border-b border-custom-border-100">
|
||||
<h3 className="text-xl font-medium">{t("common.states")}</h3>
|
||||
</div>
|
||||
{workspaceSlug && projectId && (
|
||||
<ProjectStateRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default StatesSettingsPage;
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// ui
|
||||
import { Settings } from "lucide-react";
|
||||
import { EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Breadcrumbs, CustomMenu, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
// hooks
|
||||
import { useProject, useUserPermissions } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane web
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
import { PROJECT_SETTINGS_LINKS } from "@/plane-web/constants/project";
|
||||
|
||||
export const ProjectSettingHeader: FC = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { loader } = useProject();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<div>
|
||||
<div className="z-50">
|
||||
<Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
|
||||
<ProjectBreadcrumb />
|
||||
<div className="hidden sm:hidden md:block lg:block">
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink label="Settings" icon={<Settings className="h-4 w-4 text-custom-text-300" />} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</div>
|
||||
<CustomMenu
|
||||
className="flex-shrink-0 block sm:block md:hidden lg:hidden"
|
||||
maxHeight="lg"
|
||||
customButton={
|
||||
<span className="text-xs px-1.5 py-1 border rounded-md text-custom-text-200 border-custom-border-300">
|
||||
Settings
|
||||
</span>
|
||||
}
|
||||
placement="bottom-start"
|
||||
closeOnSelect
|
||||
>
|
||||
{PROJECT_SETTINGS_LINKS.map(
|
||||
(item) =>
|
||||
allowPermissions(
|
||||
item.access,
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString()
|
||||
) && (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={() => router.push(`/${workspaceSlug}/projects/${projectId}${item.href}`)}
|
||||
>
|
||||
{t(item.i18n_label)}
|
||||
</CustomMenu.MenuItem>
|
||||
)
|
||||
)}
|
||||
</CustomMenu>
|
||||
</Header.LeftItem>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Layers, Lock } from "lucide-react";
|
||||
// plane constants
|
||||
import {
|
||||
EIssueLayoutTypes,
|
||||
EIssueFilterType,
|
||||
EIssuesStoreType,
|
||||
ISSUE_DISPLAY_FILTERS_BY_PAGE,
|
||||
EViewAccess,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
} from "@plane/constants";
|
||||
// types
|
||||
import {
|
||||
ICustomSearchSelectOption,
|
||||
IIssueDisplayFilterOptions,
|
||||
IIssueDisplayProperties,
|
||||
IIssueFilterOptions,
|
||||
} from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, Tooltip, Header, CustomSearchSelect } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink, SwitcherLabel } from "@/components/common";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
|
||||
// constants
|
||||
import { ViewQuickActions } from "@/components/views";
|
||||
// helpers
|
||||
import { isIssueFilterActive } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import {
|
||||
useCommandPalette,
|
||||
useEventTracker,
|
||||
useIssues,
|
||||
useLabel,
|
||||
useMember,
|
||||
useProject,
|
||||
useProjectState,
|
||||
useProjectView,
|
||||
useUserPermissions,
|
||||
} from "@/hooks/store";
|
||||
// plane web
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
|
||||
export const ProjectViewIssuesHeader: React.FC = observer(() => {
|
||||
// refs
|
||||
const parentRef = useRef(null);
|
||||
// router
|
||||
const { workspaceSlug, projectId, viewId } = useParams();
|
||||
const router = useAppRouter();
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.PROJECT_VIEW);
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { toggleCreateIssueModal } = useCommandPalette();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
const { currentProjectDetails, loader } = useProject();
|
||||
const { projectViewIds, getViewById } = useProjectView();
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
|
||||
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 handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId || !viewId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.FILTERS,
|
||||
{ [key]: newValues },
|
||||
viewId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, projectId, viewId, issueFilters, 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={Layers} />,
|
||||
};
|
||||
})
|
||||
.filter((option) => option !== undefined) as ICustomSearchSelectOption[];
|
||||
|
||||
return (
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs isLoading={loader === "init-loader"}>
|
||||
<ProjectBreadcrumb />
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/views`}
|
||||
label="Views"
|
||||
icon={<Layers className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="component"
|
||||
component={
|
||||
<CustomSearchSelect
|
||||
options={switcherOptions}
|
||||
value={viewId}
|
||||
label={<SwitcherLabel logo_props={viewDetails.logo_props} name={viewDetails.name} LabelIcon={Layers} />}
|
||||
onChange={(value: string) => {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/views/${value}`);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
|
||||
{viewDetails?.access === EViewAccess.PRIVATE ? (
|
||||
<div className="cursor-default text-custom-text-300">
|
||||
<Tooltip tooltipContent={"Private"}>
|
||||
<Lock 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}
|
||||
/>
|
||||
|
||||
<FiltersDropdown
|
||||
title="Filters"
|
||||
placement="bottom-end"
|
||||
disabled={!canUserCreateIssue}
|
||||
isFiltersApplied={isIssueFilterActive(issueFilters)}
|
||||
>
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
|
||||
}
|
||||
projectId={projectId.toString()}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
<FiltersDropdown title="Display" placement="bottom-end">
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_PAGE.issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{canUserCreateIssue ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTrackElement("PROJECT_VIEW_PAGE_HEADER");
|
||||
toggleCreateIssueModal(true, EIssuesStoreType.PROJECT_VIEW);
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
Add work item
|
||||
</Button>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<div className="hidden md:block">
|
||||
<ViewQuickActions
|
||||
parentRef={parentRef}
|
||||
customClassName="flex-shrink-0 flex items-center justify-center size-[26px] bg-custom-background-80/70 rounded"
|
||||
projectId={projectId.toString()}
|
||||
view={viewDetails}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
/>
|
||||
</div>
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { EmptyState } from "@/components/common";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProjectViewLayoutRoot } from "@/components/issues";
|
||||
// hooks
|
||||
import { useProject, useProjectView } from "@/hooks/store";
|
||||
// assets
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import emptyView from "@/public/empty-state/view.svg";
|
||||
|
||||
const ProjectViewIssuesPage = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, projectId, viewId } = useParams();
|
||||
// store hooks
|
||||
const { fetchViewDetails, getViewById } = useProjectView();
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const projectView = viewId ? getViewById(viewId.toString()) : undefined;
|
||||
const project = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
const pageTitle = project?.name && projectView?.name ? `${project?.name} - ${projectView?.name}` : undefined;
|
||||
|
||||
const { error } = useSWR(
|
||||
workspaceSlug && projectId && viewId ? `VIEW_DETAILS_${viewId.toString()}` : null,
|
||||
workspaceSlug && projectId && viewId
|
||||
? () => fetchViewDetails(workspaceSlug.toString(), projectId.toString(), viewId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
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 ProjectViewIssuesPage;
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
// local components
|
||||
import { ProjectViewIssuesHeader } from "./[viewId]/header";
|
||||
|
||||
export default function ProjectViewIssuesLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<ProjectViewIssuesHeader />} />
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { Layers } from "lucide-react";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, Header } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
import { ViewListHeader } from "@/components/views";
|
||||
// hooks
|
||||
import { useCommandPalette, useProject } from "@/hooks/store";
|
||||
// plane web
|
||||
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
|
||||
|
||||
export const ProjectViewsHeader = observer(() => {
|
||||
// store hooks
|
||||
const { toggleCreateViewModal } = useCommandPalette();
|
||||
const { loader } = useProject();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<Breadcrumbs isLoading={loader === "init-loader"}>
|
||||
<ProjectBreadcrumb />
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={<BreadcrumbLink label="Views" icon={<Layers className="h-4 w-4 text-custom-text-300" />} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem>
|
||||
<ViewListHeader />
|
||||
<div>
|
||||
<Button variant="primary" size="sm" onClick={() => toggleCreateViewModal(true)}>
|
||||
Add view
|
||||
</Button>
|
||||
</div>
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
// local components
|
||||
import { ProjectViewsHeader } from "./header";
|
||||
import { ViewMobileHeader } from "./mobile-header";
|
||||
|
||||
export default function ProjectViewsListLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<ProjectViewsHeader />} mobileHeader={<ViewMobileHeader />} />
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// icons
|
||||
import { ChevronDown, ListFilter } from "lucide-react";
|
||||
// components
|
||||
import { Row } from "@plane/ui";
|
||||
import { FiltersDropdown } from "@/components/issues/issue-layouts";
|
||||
import { ViewFiltersSelection } from "@/components/views/filters/filter-selection";
|
||||
import { ViewOrderByDropdown } from "@/components/views/filters/order-by";
|
||||
// hooks
|
||||
import { useMember, useProjectView } from "@/hooks/store";
|
||||
|
||||
export const ViewMobileHeader = observer(() => {
|
||||
// store hooks
|
||||
const { filters, updateFilters } = useProjectView();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="md:hidden flex justify-evenly border-b border-custom-border-200 py-2 z-[13] bg-custom-background-100">
|
||||
<Row className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<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-custom-border-200 text-sm text-custom-text-200">
|
||||
<FiltersDropdown
|
||||
icon={<ListFilter className="h-3 w-3" />}
|
||||
title="Filters"
|
||||
placement="bottom-end"
|
||||
isFiltersApplied={false}
|
||||
menuButton={
|
||||
<Row className="flex items-center text-sm text-custom-text-200">
|
||||
Filters
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" strokeWidth={2} />
|
||||
</Row>
|
||||
}
|
||||
>
|
||||
<ViewFiltersSelection
|
||||
filters={filters}
|
||||
handleFiltersUpdate={updateFilters}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// components
|
||||
import { EUserPermissionsLevel, EUserProjectRoles, EViewAccess } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TViewFilterProps } from "@plane/types";
|
||||
import { Header, EHeaderVariant } from "@plane/ui";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { DetailedEmptyState } from "@/components/empty-state";
|
||||
import { ProjectViewsList } from "@/components/views";
|
||||
import { ViewAppliedFiltersList } from "@/components/views/applied-filters";
|
||||
// constants
|
||||
// helpers
|
||||
import { calculateTotalFilters } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import { useProject, useProjectView, useUserPermissions } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
|
||||
const ProjectViewsPage = observer(() => {
|
||||
// router
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store
|
||||
const { getProjectById, currentProjectDetails } = useProject();
|
||||
const { filters, updateFilters, clearAllFilters } = useProjectView();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// derived values
|
||||
const project = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
const pageTitle = project?.name ? `${project?.name} - Views` : undefined;
|
||||
const canPerformEmptyStateActions = allowPermissions([EUserProjectRoles.ADMIN], EUserPermissionsLevel.PROJECT);
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/disabled-feature/views" });
|
||||
|
||||
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;
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
|
||||
// No access to
|
||||
if (currentProjectDetails?.issue_views_view === false)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<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}/projects/${projectId}/settings/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 ProjectViewsPage;
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
// components
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
// local components
|
||||
import { ProjectsListHeader } from "@/plane-web/components/projects/header";
|
||||
import { ProjectsListMobileHeader } from "@/plane-web/components/projects/mobile-header";
|
||||
export default function ProjectListLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<ProjectsListHeader />} mobileHeader={<ProjectsListMobileHeader />} />
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { ProjectPageRoot } from "@/plane-web/components/projects/page";
|
||||
|
||||
const ProjectsPage = () => <ProjectPageRoot />;
|
||||
export default ProjectsPage;
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane web layouts
|
||||
import { ProjectAuthWrapper } from "@/plane-web/layouts/project-wrapper";
|
||||
|
||||
const ProjectDetailLayout = ({ children }: { children: ReactNode }) => {
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
return (
|
||||
<ProjectAuthWrapper workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()}>
|
||||
{children}
|
||||
</ProjectAuthWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectDetailLayout;
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
// components
|
||||
import { AppHeader, ContentWrapper } from "@/components/core";
|
||||
// local components
|
||||
import { ProjectsListHeader } from "@/plane-web/components/projects/header";
|
||||
import { ProjectsListMobileHeader } from "@/plane-web/components/projects/mobile-header";
|
||||
export default function ProjectListLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<ProjectsListHeader />} mobileHeader={<ProjectsListMobileHeader />} />
|
||||
<ContentWrapper>{children}</ContentWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { ProjectPageRoot } from "@/plane-web/components/projects/page";
|
||||
|
||||
const ProjectsPage = () => <ProjectPageRoot />;
|
||||
export default ProjectsPage;
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// plane imports
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/ui";
|
||||
// component
|
||||
import { ApiTokenListItem, CreateApiTokenModal } from "@/components/api-token";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { DetailedEmptyState } from "@/components/empty-state";
|
||||
import { APITokenSettingsLoader } from "@/components/ui";
|
||||
import { API_TOKENS_LIST } from "@/constants/fetch-keys";
|
||||
// store hooks
|
||||
import { useUserPermissions, useWorkspace } from "@/hooks/store";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
// services
|
||||
import { APITokenService } from "@/services/api_token.service";
|
||||
|
||||
const apiTokenService = new APITokenService();
|
||||
|
||||
const ApiTokensPage = observer(() => {
|
||||
// states
|
||||
const [isCreateTokenModalOpen, setIsCreateTokenModalOpen] = useState(false);
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
// derived values
|
||||
const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/workspace-settings/api-tokens" });
|
||||
|
||||
const { data: tokens } = useSWR(
|
||||
workspaceSlug && canPerformWorkspaceAdminActions ? API_TOKENS_LIST(workspaceSlug.toString()) : null,
|
||||
() =>
|
||||
workspaceSlug && canPerformWorkspaceAdminActions ? apiTokenService.getApiTokens(workspaceSlug.toString()) : null
|
||||
);
|
||||
|
||||
const pageTitle = currentWorkspace?.name
|
||||
? `${currentWorkspace.name} - ${t("workspace_settings.settings.api_tokens.title")}`
|
||||
: undefined;
|
||||
|
||||
if (workspaceUserInfo && !canPerformWorkspaceAdminActions) {
|
||||
return <NotAuthorizedView section="settings" />;
|
||||
}
|
||||
|
||||
if (!tokens) {
|
||||
return <APITokenSettingsLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<CreateApiTokenModal isOpen={isCreateTokenModalOpen} onClose={() => setIsCreateTokenModalOpen(false)} />
|
||||
<section className="w-full overflow-y-auto">
|
||||
{tokens.length > 0 ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between border-b border-custom-border-200 pb-3.5">
|
||||
<h3 className="text-xl font-medium">{t("workspace_settings.settings.api_tokens.title")}</h3>
|
||||
<Button variant="primary" onClick={() => setIsCreateTokenModalOpen(true)}>
|
||||
{t("workspace_settings.settings.api_tokens.add_token")}
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
{tokens.map((token) => (
|
||||
<ApiTokenListItem key={token.id} token={token} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-custom-border-200 pb-3.5">
|
||||
<h3 className="text-xl font-medium">{t("workspace_settings.settings.api_tokens.title")}</h3>
|
||||
<Button variant="primary" onClick={() => setIsCreateTokenModalOpen(true)}>
|
||||
{t("workspace_settings.settings.api_tokens.add_token")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<DetailedEmptyState
|
||||
title={t("workspace_settings.empty_state.api_tokens.title")}
|
||||
description={t("workspace_settings.empty_state.api_tokens.description")}
|
||||
assetPath={resolvedPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ApiTokensPage;
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// component
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
// hooks
|
||||
import { useUserPermissions, useWorkspace } from "@/hooks/store";
|
||||
// plane web components
|
||||
import { BillingRoot } from "@/plane-web/components/workspace";
|
||||
|
||||
const BillingSettingsPage = observer(() => {
|
||||
// store hooks
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
// derived values
|
||||
const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Billing & Plans` : undefined;
|
||||
|
||||
if (workspaceUserInfo && !canPerformWorkspaceAdminActions) {
|
||||
return <NotAuthorizedView section="settings" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<BillingRoot />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default BillingSettingsPage;
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { PageHead } from "@/components/core";
|
||||
import ExportGuide from "@/components/exporter/guide";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useUserPermissions, useWorkspace } from "@/hooks/store";
|
||||
|
||||
const ExportsPage = observer(() => {
|
||||
// store hooks
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// derived values
|
||||
const canPerformWorkspaceMemberActions = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
const pageTitle = currentWorkspace?.name
|
||||
? `${currentWorkspace.name} - ${t("workspace_settings.settings.exports.title")}`
|
||||
: undefined;
|
||||
|
||||
// if user is not authorized to view this page
|
||||
if (workspaceUserInfo && !canPerformWorkspaceMemberActions) {
|
||||
return <NotAuthorizedView section="settings" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div
|
||||
className={cn("w-full overflow-y-auto", {
|
||||
"opacity-60": !canPerformWorkspaceMemberActions,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center border-b border-custom-border-100 pb-3.5">
|
||||
<h3 className="text-xl font-medium">{t("workspace_settings.settings.exports.title")}</h3>
|
||||
</div>
|
||||
<ExportGuide />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ExportsPage;
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { PageHead } from "@/components/core";
|
||||
import IntegrationGuide from "@/components/integration/guide";
|
||||
// hooks
|
||||
import { useUserPermissions, useWorkspace } from "@/hooks/store";
|
||||
|
||||
const ImportsPage = observer(() => {
|
||||
// store hooks
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
// derived values
|
||||
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Imports` : undefined;
|
||||
|
||||
if (!isAdmin)
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="mt-10 flex h-full w-full justify-center p-4">
|
||||
<p className="text-sm text-custom-text-300">You are not authorized to access this page.</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className="w-full overflow-y-auto">
|
||||
<div className="flex items-center border-b border-custom-border-100 pb-3.5">
|
||||
<h3 className="text-xl font-medium">Imports</h3>
|
||||
</div>
|
||||
<IntegrationGuide />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ImportsPage;
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
"use client";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { SingleIntegrationCard } from "@/components/integration";
|
||||
import { IntegrationAndImportExportBanner, IntegrationsSettingsLoader } from "@/components/ui";
|
||||
// constants
|
||||
import { APP_INTEGRATIONS } from "@/constants/fetch-keys";
|
||||
// hooks
|
||||
import { useUserPermissions, useWorkspace } from "@/hooks/store";
|
||||
// services
|
||||
import { IntegrationService } from "@/services/integrations";
|
||||
|
||||
const integrationService = new IntegrationService();
|
||||
|
||||
const WorkspaceIntegrationsPage = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
// derived values
|
||||
const isAdmin = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Integrations` : undefined;
|
||||
|
||||
if (!isAdmin)
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="mt-10 flex h-full w-full justify-center">
|
||||
<p className="text-sm text-custom-text-300">You are not authorized to access this page.</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const { data: appIntegrations } = useSWR(workspaceSlug && isAdmin ? APP_INTEGRATIONS : null, () =>
|
||||
workspaceSlug && isAdmin ? integrationService.getAppIntegrationsList() : null
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className="w-full overflow-y-auto">
|
||||
<IntegrationAndImportExportBanner bannerName="Integrations" />
|
||||
<div>
|
||||
{appIntegrations ? (
|
||||
appIntegrations.map((integration) => (
|
||||
<SingleIntegrationCard key={integration.id} integration={integration} />
|
||||
))
|
||||
) : (
|
||||
<IntegrationsSettingsLoader />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default WorkspaceIntegrationsPage;
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
"use client";
|
||||
|
||||
import { FC, ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_ACCESS } from "@plane/constants";
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { AppHeader } from "@/components/core";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store";
|
||||
// plane web constants
|
||||
// local components
|
||||
import { WorkspaceSettingHeader } from "../header";
|
||||
import { MobileWorkspaceSettingsTabs } from "./mobile-header-tabs";
|
||||
import { WorkspaceSettingsSidebar } from "./sidebar";
|
||||
|
||||
export interface IWorkspaceSettingLayout {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const WorkspaceSettingLayout: FC<IWorkspaceSettingLayout> = observer((props) => {
|
||||
const { children } = props;
|
||||
|
||||
const { workspaceUserInfo } = useUserPermissions();
|
||||
const pathname = usePathname();
|
||||
const [workspaceSlug, suffix, route] = pathname.replace(/^\/|\/$/g, "").split("/"); // Regex removes leading and trailing slashes
|
||||
|
||||
// derived values
|
||||
const userWorkspaceRole = workspaceUserInfo?.[workspaceSlug.toString()]?.role;
|
||||
const isAuthorized =
|
||||
pathname &&
|
||||
workspaceSlug &&
|
||||
userWorkspaceRole &&
|
||||
WORKSPACE_SETTINGS_ACCESS[route ? `/${suffix}/${route}` : `/${suffix}`]?.includes(
|
||||
userWorkspaceRole as EUserWorkspaceRoles
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppHeader header={<WorkspaceSettingHeader />} />
|
||||
<MobileWorkspaceSettingsTabs />
|
||||
<div className="inset-y-0 flex flex-row vertical-scrollbar scrollbar-lg h-full w-full overflow-y-auto">
|
||||
{workspaceUserInfo && !isAuthorized ? (
|
||||
<NotAuthorizedView section="settings" />
|
||||
) : (
|
||||
<>
|
||||
<div className="px-page-x !pr-0 py-page-y flex-shrink-0 overflow-y-hidden sm:hidden hidden md:block lg:block">
|
||||
<WorkspaceSettingsSidebar />
|
||||
</div>
|
||||
<div className="flex flex-col relative w-full overflow-hidden">
|
||||
<div className="w-full h-full overflow-x-hidden overflow-y-scroll vertical-scrollbar scrollbar-md px-page-x md:px-9 py-page-y">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default WorkspaceSettingLayout;
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Search } from "lucide-react";
|
||||
// types
|
||||
import { MEMBER_INVITED, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IWorkspaceBulkInviteFormData } from "@plane/types";
|
||||
// ui
|
||||
import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
import { CountChip } from "@/components/common";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { WorkspaceMembersList } from "@/components/workspace";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { getUserRole } from "@/helpers/user.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useMember, useUserPermissions, useWorkspace } from "@/hooks/store";
|
||||
// plane web components
|
||||
import { BillingActionsButton } from "@/plane-web/components/workspace/billing";
|
||||
import { SendWorkspaceInvitationModal } from "@/plane-web/components/workspace/members";
|
||||
|
||||
const WorkspaceMembersSettingsPage = observer(() => {
|
||||
// states
|
||||
const [inviteModal, setInviteModal] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||
const { captureEvent } = useEventTracker();
|
||||
const {
|
||||
workspace: { workspaceMemberIds, inviteMembersToWorkspace },
|
||||
} = useMember();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// derived values
|
||||
const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||
const canPerformWorkspaceMemberActions = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
const handleWorkspaceInvite = (data: IWorkspaceBulkInviteFormData) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
return inviteMembersToWorkspace(workspaceSlug.toString(), data)
|
||||
.then(() => {
|
||||
setInviteModal(false);
|
||||
captureEvent(MEMBER_INVITED, {
|
||||
emails: [
|
||||
...data.emails.map((email) => ({
|
||||
email: email.email,
|
||||
role: getUserRole(email.role as unknown as EUserPermissions),
|
||||
})),
|
||||
],
|
||||
project_id: undefined,
|
||||
state: "SUCCESS",
|
||||
element: "Workspace settings member page",
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: t("workspace_settings.settings.members.invitations_sent_successfully"),
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
captureEvent(MEMBER_INVITED, {
|
||||
emails: [
|
||||
...data.emails.map((email) => ({
|
||||
email: email.email,
|
||||
role: getUserRole(email.role as unknown as EUserPermissions),
|
||||
})),
|
||||
],
|
||||
project_id: undefined,
|
||||
state: "FAILED",
|
||||
element: "Workspace settings member page",
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: `${err.error ?? t("something_went_wrong_please_try_again")}`,
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
};
|
||||
|
||||
// derived values
|
||||
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Members` : undefined;
|
||||
|
||||
// if user is not authorized to view this page
|
||||
if (workspaceUserInfo && !canPerformWorkspaceMemberActions) {
|
||||
return <NotAuthorizedView section="settings" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<SendWorkspaceInvitationModal
|
||||
isOpen={inviteModal}
|
||||
onClose={() => setInviteModal(false)}
|
||||
onSubmit={handleWorkspaceInvite}
|
||||
/>
|
||||
<section
|
||||
className={cn("w-full h-full overflow-y-auto", {
|
||||
"opacity-60": !canPerformWorkspaceMemberActions,
|
||||
})}
|
||||
>
|
||||
<div className="flex justify-between gap-4 pb-3.5 items-start">
|
||||
<h4 className="flex items-center gap-2.5 text-xl font-medium">
|
||||
{t("workspace_settings.settings.members.title")}
|
||||
{workspaceMemberIds && workspaceMemberIds.length > 0 && (
|
||||
<CountChip count={workspaceMemberIds.length} className="h-5 m-auto" />
|
||||
)}
|
||||
</h4>
|
||||
<div className="ml-auto flex items-center gap-1.5 rounded-md border border-custom-border-200 bg-custom-background-100 px-2.5 py-1.5">
|
||||
<Search className="h-3.5 w-3.5 text-custom-text-400" />
|
||||
<input
|
||||
className="w-full max-w-[234px] border-none bg-transparent text-sm outline-none placeholder:text-custom-text-400"
|
||||
placeholder={`${t("search")}...`}
|
||||
value={searchQuery}
|
||||
autoFocus
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{canPerformWorkspaceAdminActions && (
|
||||
<Button variant="primary" size="sm" onClick={() => setInviteModal(true)}>
|
||||
{t("workspace_settings.settings.members.add_member")}
|
||||
</Button>
|
||||
)}
|
||||
<BillingActionsButton canPerformWorkspaceAdminActions={canPerformWorkspaceAdminActions} />
|
||||
</div>
|
||||
<WorkspaceMembersList searchQuery={searchQuery} isAdmin={canPerformWorkspaceAdminActions} />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default WorkspaceMembersSettingsPage;
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { WORKSPACE_SETTINGS_LINKS, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// plane web helpers
|
||||
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
|
||||
|
||||
export const MobileWorkspaceSettingsTabs = observer(() => {
|
||||
const router = useAppRouter();
|
||||
const { workspaceSlug } = useParams();
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslation();
|
||||
// mobx store
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
return (
|
||||
<div className="flex-shrink-0 md:hidden sticky inset-0 flex overflow-x-auto bg-custom-background-100 z-10">
|
||||
{WORKSPACE_SETTINGS_LINKS.map(
|
||||
(item, index) =>
|
||||
shouldRenderSettingLink(workspaceSlug.toString(), item.key) &&
|
||||
allowPermissions(item.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && (
|
||||
<div
|
||||
className={`${
|
||||
item.highlight(pathname, `/${workspaceSlug}`)
|
||||
? "text-custom-primary-100 text-sm py-2 px-3 whitespace-nowrap flex flex-grow cursor-pointer justify-around border-b border-custom-primary-200"
|
||||
: "text-custom-text-200 flex flex-grow cursor-pointer justify-around border-b border-custom-border-200 text-sm py-2 px-3 whitespace-nowrap"
|
||||
}`}
|
||||
key={index}
|
||||
onClick={() => router.push(`/${workspaceSlug}${item.href}`)}
|
||||
>
|
||||
{t(item.i18n_label)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { WorkspaceDetails } from "@/components/workspace";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
|
||||
const WorkspaceSettingsPage = observer(() => {
|
||||
// store hooks
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { t } = useTranslation();
|
||||
// derived values
|
||||
const pageTitle = currentWorkspace?.name
|
||||
? t("workspace_settings.page_label", { workspace: currentWorkspace.name })
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<WorkspaceDetails />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default WorkspaceSettingsPage;
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import { WORKSPACE_SETTINGS_LINKS, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// components
|
||||
import { SidebarNavItem } from "@/components/sidebar";
|
||||
// hooks
|
||||
import { useUserPermissions } from "@/hooks/store";
|
||||
// plane web helpers
|
||||
import { shouldRenderSettingLink } from "@/plane-web/helpers/workspace.helper";
|
||||
|
||||
export const WorkspaceSettingsSidebar = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
const pathname = usePathname();
|
||||
// mobx store
|
||||
const { t } = useTranslation();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
return (
|
||||
<div className="flex w-[280px] flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-semibold text-custom-sidebar-text-400 uppercase">{t("settings")}</span>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{WORKSPACE_SETTINGS_LINKS.map(
|
||||
(link) =>
|
||||
shouldRenderSettingLink(workspaceSlug.toString(), link.key) &&
|
||||
allowPermissions(link.access, EUserPermissionsLevel.WORKSPACE, workspaceSlug.toString()) && (
|
||||
<Link key={link.key} href={`/${workspaceSlug}${link.href}`}>
|
||||
<SidebarNavItem
|
||||
key={link.key}
|
||||
isActive={link.highlight(pathname, `/${workspaceSlug}`)}
|
||||
className="text-sm font-medium px-4 py-2"
|
||||
>
|
||||
{t(link.i18n_label)}
|
||||
</SidebarNavItem>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue