Compare commits

...
Author SHA1 Message Date
gakshita eb43542a0c fix: revamped box containers 2024-08-28 17:56:20 +05:30
gakshita 2674413d0c fix: home header 2024-08-28 17:51:02 +05:30
gakshita c224f493d4 fix: filters code splitting 2024-08-28 17:41:25 +05:30
gakshita f099f7f961 chore: headers + common containers 2024-08-28 17:35:20 +05:30
a0ed51c845 [WEB-2293] feat: pages version history (#5417)
* chore: project page version

* feat: page version history implemented

* chore: hide save button when version history overlay is active

* refactor: updated navigation logic

* chore: added error states

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-08-26 14:03:55 +05:30
Anmol Singh BhatiaandGitHub d802316c5c [WEB-2263] fix: god mode wrong credentials error message banner (#5407)
* fix: god mode wrong credentials error message banner

* chore: code refactor
2024-08-26 13:07:00 +05:30
Anmol Singh BhatiaandGitHub bd3f117545 [PWA-2] fix: pwa input zoom effect (#5402)
* fix: pwa input zoom effect

* fix: pwa input zoom effect

* fix: pwa input zoom effect

* fix: pwa sticky issue comment

* chore: code refactor

* chore: code refactor
2024-08-26 13:02:30 +05:30
Anmol Singh BhatiaandGitHub 9065932c86 fix: pwa sticky issue comment (#5419) 2024-08-23 19:06:12 +05:30
Prateek ShouryaandGitHub 700f3ee823 chore: pricing update. (#5410) 2024-08-23 18:04:55 +05:30
rahulrameshaandGitHub adf891bcba [WEB-2150] fix: issue selection redirect alert (#5406)
* fix issue selection redirect alert

* change message content for user prompt
2024-08-23 18:00:15 +05:30
81 changed files with 1852 additions and 881 deletions
@@ -0,0 +1,29 @@
import { FC } from "react";
import { Info, X } from "lucide-react";
// helpers
import { TAuthErrorInfo } from "@/helpers/authentication.helper";
type TAuthBanner = {
bannerData: TAuthErrorInfo | undefined;
handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void;
};
export const AuthBanner: FC<TAuthBanner> = (props) => {
const { bannerData, handleBannerData } = props;
if (!bannerData) return <></>;
return (
<div className="relative flex items-center p-2 rounded-md gap-2 border border-custom-primary-100/50 bg-custom-primary-100/10">
<div className="w-4 h-4 flex-shrink-0 relative flex justify-center items-center">
<Info size={16} className="text-custom-primary-100" />
</div>
<div className="w-full text-sm font-medium text-custom-primary-100">{bannerData?.message}</div>
<div
className="relative ml-auto w-6 h-6 rounded-sm flex justify-center items-center transition-all cursor-pointer hover:bg-custom-primary-100/20 text-custom-primary-100/80"
onClick={() => handleBannerData && handleBannerData(undefined)}
>
<X className="w-4 h-4 flex-shrink-0" />
</div>
</div>
);
};
@@ -1,3 +1,4 @@
export * from "./auth-banner";
export * from "./email-config-switch";
export * from "./password-config-switch";
export * from "./authentication-method-card";
+23 -1
View File
@@ -8,8 +8,16 @@ import { Button, Input, Spinner } from "@plane/ui";
// components
import { Banner } from "@/components/common";
// helpers
import {
authErrorHandler,
EAuthenticationErrorCodes,
EErrorAlertType,
TAuthErrorInfo,
} from "@/helpers/authentication.helper";
import { API_BASE_URL } from "@/helpers/common.helper";
import { AuthService } from "@/services/auth.service";
import { AuthBanner } from "../authentication";
// ui
// icons
@@ -53,6 +61,7 @@ export const InstanceSignInForm: FC = (props) => {
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
const [formData, setFormData] = useState<TFormData>(defaultFromData);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
setFormData((prev) => ({ ...prev, [key]: value }));
@@ -91,6 +100,15 @@ export const InstanceSignInForm: FC = (props) => {
[formData.email, formData.password, isSubmitting]
);
useEffect(() => {
if (errorCode) {
const errorDetail = authErrorHandler(errorCode?.toString() as EAuthenticationErrorCodes);
if (errorDetail) {
setErrorInfo(errorDetail);
}
}
}, [errorCode]);
return (
<div className="flex-grow container mx-auto max-w-lg px-10 lg:max-w-md lg:px-5 py-10 lg:pt-28 transition-all">
<div className="relative flex flex-col space-y-6">
@@ -103,7 +121,11 @@ export const InstanceSignInForm: FC = (props) => {
</p>
</div>
{errorData.type && errorData?.message && <Banner type="error" message={errorData?.message} />}
{errorData.type && errorData?.message ? (
<Banner type="error" message={errorData?.message} />
) : (
<>{errorInfo && <AuthBanner bannerData={errorInfo} handleBannerData={(value) => setErrorInfo(value)} />}</>
)}
<form
className="space-y-4"
@@ -92,6 +92,7 @@ from .page import (
SubPageSerializer,
PageDetailSerializer,
PageVersionSerializer,
PageVersionDetailSerializer,
)
from .estimate import (
+34 -1
View File
@@ -167,7 +167,40 @@ class PageLogSerializer(BaseSerializer):
class PageVersionSerializer(BaseSerializer):
class Meta:
model = PageVersion
fields = "__all__"
fields = [
"id",
"workspace",
"page",
"last_saved_at",
"owned_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
read_only_fields = [
"workspace",
"page",
]
class PageVersionDetailSerializer(BaseSerializer):
class Meta:
model = PageVersion
fields = [
"id",
"workspace",
"page",
"last_saved_at",
"description_binary",
"description_html",
"description_json",
"owned_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
read_only_fields = [
"workspace",
"page",
+9 -7
View File
@@ -5,16 +5,18 @@ from rest_framework.response import Response
# Module imports
from plane.db.models import PageVersion
from ..base import BaseAPIView
from plane.app.permissions import ProjectEntityPermission
from plane.app.serializers import PageVersionSerializer
from plane.app.serializers import (
PageVersionSerializer,
PageVersionDetailSerializer,
)
from plane.app.permissions import allow_permission, ROLE
class PageVersionEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.VIEWER, ROLE.GUEST]
)
def get(self, request, slug, project_id, page_id, pk=None):
# Check if pk is provided
if pk:
@@ -25,7 +27,7 @@ class PageVersionEndpoint(BaseAPIView):
pk=pk,
)
# Serialize the page version
serializer = PageVersionSerializer(page_version)
serializer = PageVersionDetailSerializer(page_version)
return Response(serializer.data, status=status.HTTP_200_OK)
# Return all page versions
page_versions = PageVersion.objects.filter(
+2 -2
View File
@@ -126,8 +126,8 @@ export const useEditor = (props: CustomEditorProps) => {
useImperativeHandle(
forwardedRef,
() => ({
clearEditor: () => {
editorRef.current?.commands.clearContent();
clearEditor: (emitUpdate = false) => {
editorRef.current?.commands.clearContent(emitUpdate);
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content);
+1 -1
View File
@@ -6,7 +6,7 @@ import { IMentionHighlight, IMentionSuggestion, TDisplayConfig, TEditorCommands,
export type EditorReadOnlyRefApi = {
getMarkDown: () => string;
getHTML: () => string;
clearEditor: () => void;
clearEditor: (emitUpdate?: boolean) => void;
setEditorValue: (content: string) => void;
scrollSummary: (marking: IMarking) => void;
};
@@ -333,6 +333,8 @@ module.exports = {
72: "16.2rem",
80: "18rem",
96: "21.6rem",
"page-x": "1.35rem",
"page-y": "1.35rem"
},
margin: {
0: "0",
@@ -434,5 +436,23 @@ module.exports = {
custom: ["Inter", "sans-serif"],
},
},
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography"), function({ addUtilities }) {
const newUtilities = {
// Mobile screens
'.px-page-x': {
paddingLeft: '0.675rem',
paddingRight: '0.675rem',
},
// Medium screens (768px and up)
'@media (min-width: 768px)': {
'.px-page-x': {
paddingLeft: '1.35rem',
paddingRight: '1.35rem',
},
}
};
addUtilities(newUtilities, ['responsive']);
},
],
};
+16
View File
@@ -48,3 +48,19 @@ export type TPageFilters = {
};
export type TPageEmbedType = "mention" | "issue";
export type TPageVersion = {
created_at: string;
created_by: string;
deleted_at: string | null;
description_binary?: string | null;
description_html?: string | null;
description_json?: object;
id: string;
last_saved_at: string;
owned_by: string;
page: string;
updated_at: string;
updated_by: string;
workspace: string;
}
@@ -6,25 +6,24 @@ import { Breadcrumbs, ContrastIcon } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
// plane web components
import { HeaderContainer } from "@/components/containers";
import { UpgradeBadge } from "@/plane-web/components/workspace";
export const WorkspaceActiveCycleHeader = observer(() => (
<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">
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
label="Active Cycles"
icon={<ContrastIcon className="h-4 w-4 text-custom-text-300 rotate-180" />}
/>
}
/>
</Breadcrumbs>
<UpgradeBadge size="md" />
</div>
</div>
</div>
<HeaderContainer>
<HeaderContainer.LeftItem>
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
label="Active Cycles"
icon={<ContrastIcon className="h-4 w-4 text-custom-text-300 rotate-180" />}
/>
}
/>
</Breadcrumbs>
<UpgradeBadge size="md" />
</HeaderContainer.LeftItem>
</HeaderContainer>
));
@@ -10,6 +10,7 @@ import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
// helpers
import { HeaderContainer } from "@/components/containers";
import { cn } from "@/helpers/common.helper";
// hooks
import { useAppTheme } from "@/hooks/store";
@@ -37,37 +38,33 @@ export const WorkspaceAnalyticsHeader = observer(() => {
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 w-full items-center justify-between">
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink label="Analytics" icon={<BarChart2 className="h-4 w-4 text-custom-text-300" />} />
}
<HeaderContainer>
<HeaderContainer.LeftItem>
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
type="text"
link={<BreadcrumbLink label="Analytics" 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"
)}
/>
</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>
)}
</div>
</div>
</div>
</button>
) : (
<></>
)}
</HeaderContainer.LeftItem>
</HeaderContainer>
</>
);
});
@@ -11,6 +11,7 @@ import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
// constants
import { HeaderContainer } from "@/components/containers";
import { CHANGELOG_REDIRECTED, GITHUB_REDIRECTED } from "@/constants/event-tracker";
// hooks
import { useEventTracker } from "@/hooks/store";
@@ -22,8 +23,8 @@ export const WorkspaceDashboardHeader = () => {
return (
<>
<div className="relative z-[15] 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 items-center gap-2 overflow-ellipsis whitespace-nowrap">
<HeaderContainer>
<HeaderContainer.LeftItem>
<div>
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
@@ -32,8 +33,8 @@ export const WorkspaceDashboardHeader = () => {
/>
</Breadcrumbs>
</div>
</div>
<div className="flex items-center gap-3 px-3">
</HeaderContainer.LeftItem>
<HeaderContainer.RightItem>
<a
onClick={() =>
captureEvent(CHANGELOG_REDIRECTED, {
@@ -67,8 +68,8 @@ export const WorkspaceDashboardHeader = () => {
/>
<span className="hidden text-xs font-medium sm:hidden md:block">Star us on GitHub</span>
</a>
</div>
</div>
</HeaderContainer.RightItem>
</HeaderContainer>
</>
);
};
@@ -10,7 +10,7 @@ import { IUserProfileProjectSegregation } from "@plane/types";
import { Breadcrumbs, CustomMenu, UserActivityIcon } from "@plane/ui";
import { BreadcrumbLink } from "@/components/common";
// components
import { ProfileIssuesFilter } from "@/components/profile";
import { HeaderContainer } from "@/components/containers";
import { PROFILE_ADMINS_TAB, PROFILE_VIEWER_TAB } from "@/constants/profile";
import { EUserWorkspaceRoles } from "@/constants/workspace";
import { cn } from "@/helpers/common.helper";
@@ -23,7 +23,7 @@ type TUserProfileHeader = {
};
export const UserProfileHeader: FC<TUserProfileHeader> = observer((props) => {
const { userProjectsData, type = undefined, showProfileIssuesFilter } = props;
const { userProjectsData, type = undefined } = props;
// router
const { workspaceSlug, userId } = useParams();
// store hooks
@@ -46,8 +46,8 @@ export const UserProfileHeader: FC<TUserProfileHeader> = observer((props) => {
const breadcrumbLabel = `${isCurrentUser ? "Your" : userName} Work`;
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">
<HeaderContainer>
<HeaderContainer.LeftItem>
<div className="flex w-full justify-between">
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
@@ -61,7 +61,7 @@ export const UserProfileHeader: FC<TUserProfileHeader> = observer((props) => {
}
/>
</Breadcrumbs>
<div className="hidden md:flex md:items-center">{showProfileIssuesFilter && <ProfileIssuesFilter />}</div>
{/* <div className="hidden md:flex md:items-center">{showProfileIssuesFilter && <ProfileIssuesFilter />}</div> */}
<div className="flex gap-4 md:hidden">
<CustomMenu
maxHeight={"md"}
@@ -104,7 +104,7 @@ export const UserProfileHeader: FC<TUserProfileHeader> = observer((props) => {
</button>
</div>
</div>
</div>
</div>
</HeaderContainer.LeftItem>
</HeaderContainer>
);
});
@@ -8,6 +8,7 @@ import { ArchiveIcon, Breadcrumbs, Tooltip } from "@plane/ui";
// components
import { BreadcrumbLink, Logo } from "@/components/common";
// constants
import { HeaderContainer } from "@/components/containers";
import { PROJECT_ARCHIVES_BREADCRUMB_LIST } from "@/constants/archives";
import { EIssuesStoreType } from "@/constants/issue";
// hooks
@@ -16,8 +17,8 @@ import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
type TProps = {
activeTab: 'issues' | 'cycles' | 'modules';
}
activeTab: "issues" | "cycles" | "modules";
};
export const ProjectArchivesHeader: FC<TProps> = observer((props: TProps) => {
const { activeTab } = props;
@@ -38,8 +39,8 @@ export const ProjectArchivesHeader: FC<TProps> = observer((props: TProps) => {
PROJECT_ARCHIVES_BREADCRUMB_LIST[activeTab as keyof typeof PROJECT_ARCHIVES_BREADCRUMB_LIST];
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">
<HeaderContainer>
<HeaderContainer.LeftItem>
<div className="flex items-center gap-2.5">
<Breadcrumbs onBack={router.back} isLoading={loader}>
<Breadcrumbs.BreadcrumbItem
@@ -92,7 +93,7 @@ export const ProjectArchivesHeader: FC<TProps> = observer((props: TProps) => {
</Tooltip>
) : null}
</div>
</div>
</div>
</HeaderContainer.LeftItem>
</HeaderContainer>
);
});
@@ -7,6 +7,7 @@ import useSWR from "swr";
import { ArchiveIcon, Breadcrumbs, LayersIcon } from "@plane/ui";
// components
import { BreadcrumbLink, Logo } from "@/components/common";
import { HeaderContainer } from "@/components/containers";
import { IssueDetailQuickActions } from "@/components/issues";
// constants
import { ISSUE_DETAILS } from "@/constants/fetch-keys";
@@ -36,66 +37,66 @@ export const ProjectArchivedIssueDetailsHeader = observer(() => {
);
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>
<Breadcrumbs isLoading={loader}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${workspaceSlug}/projects`}
label={currentProjectDetails?.name ?? "Project"}
icon={
currentProjectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
}
/>
}
/>
<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="Issues"
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>
</div>
</div>
<IssueDetailQuickActions
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
issueId={archivedIssueId.toString()}
/>
</div>
<HeaderContainer>
<HeaderContainer.LeftItem>
<Breadcrumbs isLoading={loader}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${workspaceSlug}/projects`}
label={currentProjectDetails?.name ?? "Project"}
icon={
currentProjectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
}
/>
}
/>
<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="Issues"
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>
</HeaderContainer.LeftItem>
<HeaderContainer.RightItem>
<IssueDetailQuickActions
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
issueId={archivedIssueId.toString()}
/>
</HeaderContainer.RightItem>
</HeaderContainer>
);
});
@@ -13,6 +13,7 @@ import { Breadcrumbs, Button, ContrastIcon, CustomMenu, Tooltip } from "@plane/u
// components
import { ProjectAnalyticsModal } from "@/components/analytics";
import { BreadcrumbLink, Logo } from "@/components/common";
import { HeaderContainer } from "@/components/containers";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
// constants
import {
@@ -161,8 +162,8 @@ export const CycleIssuesHeader: React.FC = observer(() => {
onClose={() => setAnalyticsModal(false)}
cycleDetails={cycleDetails ?? undefined}
/>
<div className="relative z-[15] w-full items-center gap-x-2 gap-y-4">
<div className="flex justify-between bg-custom-sidebar-background-100 p-4">
<HeaderContainer>
<HeaderContainer.LeftItem>
<div className="flex items-center gap-2">
<Breadcrumbs onBack={router.back} isLoading={loader}>
<Breadcrumbs.BreadcrumbItem
@@ -235,6 +236,8 @@ export const CycleIssuesHeader: React.FC = observer(() => {
/>
</Breadcrumbs>
</div>
</HeaderContainer.LeftItem>
<HeaderContainer.RightItem>
<div className="hidden items-center gap-2 md:flex ">
<LayoutSelection
layouts={[
@@ -315,8 +318,8 @@ export const CycleIssuesHeader: React.FC = observer(() => {
>
<PanelRight className={cn("h-4 w-4", !isSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200")} />
</button>
</div>
</div>
</HeaderContainer.RightItem>
</HeaderContainer>
</>
);
});
@@ -9,6 +9,7 @@ import { Breadcrumbs, Button, ContrastIcon } from "@plane/ui";
import { BreadcrumbLink, Logo } from "@/components/common";
import { CyclesViewHeader } from "@/components/cycles";
// constants
import { HeaderContainer } from "@/components/containers";
import { EUserProjectRoles } from "@/constants/project";
// hooks
import { useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store";
@@ -30,48 +31,50 @@ export const CyclesListHeader: FC = observer(() => {
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
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>
<Breadcrumbs onBack={router.back} isLoading={loader}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
label={currentProjectDetails?.name ?? "Project"}
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
icon={
currentProjectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={<BreadcrumbLink label="Cycles" icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />} />}
/>
</Breadcrumbs>
</div>
</div>
{canUserCreateCycle && currentProjectDetails && (
<div className="flex items-center gap-3">
<CyclesViewHeader projectId={currentProjectDetails.id} />
<Button
variant="primary"
size="sm"
onClick={() => {
setTrackElement("Cycles page");
toggleCreateCycleModal(true);
}}
>
<div className="hidden sm:block">Add</div> Cycle
</Button>
</div>
)}
</div>
<HeaderContainer>
<HeaderContainer.LeftItem>
<Breadcrumbs onBack={router.back} isLoading={loader}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
label={currentProjectDetails?.name ?? "Project"}
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
icon={
currentProjectDetails && (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={<BreadcrumbLink label="Cycles" icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />} />}
/>
</Breadcrumbs>
</HeaderContainer.LeftItem>
<HeaderContainer.RightItem>
{canUserCreateCycle && currentProjectDetails ? (
<div className="flex items-center gap-3">
<CyclesViewHeader projectId={currentProjectDetails.id} />
<Button
variant="primary"
size="sm"
onClick={() => {
setTrackElement("Cycles page");
toggleCreateCycleModal(true);
}}
>
<div className="hidden sm:block">Add</div> Cycle
</Button>
</div>
) : (
<></>
)}
</HeaderContainer.RightItem>
</HeaderContainer>
);
});
@@ -8,6 +8,7 @@ import { RefreshCcw } from "lucide-react";
import { Breadcrumbs, Button, Intake } from "@plane/ui";
// components
import { BreadcrumbLink, Logo } from "@/components/common";
import { HeaderContainer } from "@/components/containers";
import { InboxIssueCreateEditModalRoot } from "@/components/inbox";
// constants
import { EUserProjectRoles } from "@/constants/project";
@@ -30,8 +31,8 @@ export const ProjectInboxHeader: FC = observer(() => {
const isViewer = currentProjectRole === EUserProjectRoles.VIEWER;
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">
<HeaderContainer>
<HeaderContainer.LeftItem>
<div className="flex items-center gap-4">
<Breadcrumbs isLoading={currentProjectDetailsLoader}>
<Breadcrumbs.BreadcrumbItem
@@ -64,23 +65,26 @@ export const ProjectInboxHeader: FC = observer(() => {
</div>
)}
</div>
</div>
</HeaderContainer.LeftItem>
<HeaderContainer.RightItem>
{currentProjectDetails?.inbox_view && workspaceSlug && projectId && !isViewer ? (
<div className="flex items-center gap-2">
<InboxIssueCreateEditModalRoot
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
modalState={createIssueModal}
handleModalClose={() => setCreateIssueModal(false)}
issue={undefined}
/>
{currentProjectDetails?.inbox_view && workspaceSlug && projectId && !isViewer && (
<div className="flex items-center gap-2">
<InboxIssueCreateEditModalRoot
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
modalState={createIssueModal}
handleModalClose={() => setCreateIssueModal(false)}
issue={undefined}
/>
<Button variant="primary" size="sm" onClick={() => setCreateIssueModal(true)}>
Add issue
</Button>
</div>
)}
</div>
<Button variant="primary" size="sm" onClick={() => setCreateIssueModal(true)}>
Add issue
</Button>
</div>
) : (
<></>
)}
</HeaderContainer.RightItem>
</HeaderContainer>
);
});
@@ -7,6 +7,7 @@ import { PanelRight } from "lucide-react";
import { Breadcrumbs, LayersIcon } from "@plane/ui";
// components
import { BreadcrumbLink, Logo } from "@/components/common";
import { HeaderContainer } from "@/components/containers";
import { IssueDetailQuickActions } from "@/components/issues";
// helpers
import { cn } from "@/helpers/common.helper";
@@ -29,8 +30,8 @@ export const ProjectIssueDetailsHeader = observer(() => {
const isSidebarCollapsed = issueDetailSidebarCollapsed;
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">
<HeaderContainer>
<HeaderContainer.LeftItem>
<div>
<Breadcrumbs onBack={router.back} isLoading={loader}>
<Breadcrumbs.BreadcrumbItem
@@ -75,17 +76,19 @@ export const ProjectIssueDetailsHeader = observer(() => {
/>
</Breadcrumbs>
</div>
</div>
<IssueDetailQuickActions
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
issueId={issueId.toString()}
/>
<button className="block md:hidden" onClick={() => toggleIssueDetailSidebar()}>
<PanelRight
className={cn("h-4 w-4 ", !isSidebarCollapsed ? "text-custom-primary-100" : " text-custom-text-200")}
</HeaderContainer.LeftItem>
<HeaderContainer.RightItem>
<IssueDetailQuickActions
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
issueId={issueId.toString()}
/>
</button>
</div>
<button className="block md:hidden" onClick={() => toggleIssueDetailSidebar()}>
<PanelRight
className={cn("h-4 w-4 ", !isSidebarCollapsed ? "text-custom-primary-100" : " text-custom-text-200")}
/>
</button>
</HeaderContainer.RightItem>
</HeaderContainer>
);
});
@@ -1,130 +1,55 @@
"use client";
import { useCallback, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// icons
import { Briefcase, Circle, ExternalLink } from "lucide-react";
// types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
// ui
import { Breadcrumbs, Button, LayersIcon, Tooltip } from "@plane/ui";
// components
import { ProjectAnalyticsModal } from "@/components/analytics";
import { BreadcrumbLink, CountChip, Logo } from "@/components/common";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
import { HeaderContainer } from "@/components/containers";
// constants
import {
EIssueFilterType,
EIssuesStoreType,
EIssueLayoutTypes,
ISSUE_DISPLAY_FILTERS_BY_LAYOUT,
} from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project";
import HeaderFilters from "@/components/issues/filters";
import { EIssuesStoreType } from "@/constants/issue";
// helpers
import { EUserProjectRoles } from "@/constants/project";
import { SPACE_BASE_PATH, SPACE_BASE_URL } from "@/helpers/common.helper";
import { isIssueFilterActive } from "@/helpers/filter.helper";
// hooks
import {
useEventTracker,
useLabel,
useProject,
useProjectState,
useUser,
useMember,
useCommandPalette,
} from "@/hooks/store";
import { useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store";
import { useIssues } from "@/hooks/store/use-issues";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
export const ProjectIssuesHeader = observer(() => {
// states
const [analyticsModal, setAnalyticsModal] = useState(false);
// router
const router = useAppRouter();
const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string };
// store hooks
const {
project: { projectMemberIds },
} = useMember();
const {
issuesFilter: { issueFilters, updateFilters },
issues: { getGroupIssueCount },
} = useIssues(EIssuesStoreType.PROJECT);
const { toggleCreateIssueModal } = useCommandPalette();
const { setTrackElement } = useEventTracker();
const {
membership: { currentProjectRole },
} = useUser();
const {
issues: { getGroupIssueCount },
} = useIssues(EIssuesStoreType.PROJECT);
const { currentProjectDetails, loader } = useProject();
const { projectStates } = useProjectState();
const { projectLabels } = useLabel();
const { toggleCreateIssueModal } = useCommandPalette();
const { setTrackElement } = useEventTracker();
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 SPACE_APP_URL = (SPACE_BASE_URL.trim() === "" ? window.location.origin : SPACE_BASE_URL) + SPACE_BASE_PATH;
const publishedURL = `${SPACE_APP_URL}/issues/${currentProjectDetails?.anchor}`;
const issuesCount = getGroupIssueCount(undefined, undefined, false);
const canUserCreateIssue =
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
const issuesCount = getGroupIssueCount(undefined, undefined, false);
return (
<>
<ProjectAnalyticsModal
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
projectDetails={currentProjectDetails ?? undefined}
/>
<div className="relative z-[15] 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">
<HeaderContainer>
<HeaderContainer.LeftItem>
<div className="flex items-center gap-2.5">
<Breadcrumbs onBack={() => router.back()} isLoading={loader}>
<Breadcrumbs.BreadcrumbItem
@@ -165,7 +90,7 @@ export const ProjectIssuesHeader = observer(() => {
</Tooltip>
) : null}
</div>
{currentProjectDetails?.anchor && (
{currentProjectDetails?.anchor ? (
<a
href={publishedURL}
className="group flex items-center gap-1.5 rounded bg-custom-primary-100/10 px-2.5 py-1 text-xs font-medium text-custom-primary-100"
@@ -176,61 +101,20 @@ export const ProjectIssuesHeader = observer(() => {
Public
<ExternalLink className="hidden h-3 w-3 group-hover:block" strokeWidth={2} />
</a>
) : (
<></>
)}
</div>
<div className="items-center gap-2 hidden 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_LAYOUT.issues[activeLayout] : undefined
}
labels={projectLabels}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
</HeaderContainer.LeftItem>
<HeaderContainer.RightItem>
<div className="hidden gap-3 md:flex">
<HeaderFilters
projectId={projectId}
currentProjectDetails={currentProjectDetails}
workspaceSlug={workspaceSlug}
canUserCreateIssue={canUserCreateIssue}
/>
</FiltersDropdown>
<FiltersDropdown title="Display" placement="bottom-end">
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
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>
</div>
{canUserCreateIssue ? (
<Button
onClick={() => {
setTrackElement("Project issues page");
@@ -240,9 +124,11 @@ export const ProjectIssuesHeader = observer(() => {
>
<div className="hidden sm:block">Add</div> Issue
</Button>
</>
)}
</div>
) : (
<></>
)}
</HeaderContainer.RightItem>
</HeaderContainer>
</>
);
});
@@ -13,6 +13,7 @@ import { Breadcrumbs, Button, CustomMenu, DiceIcon, Tooltip } from "@plane/ui";
// components
import { ProjectAnalyticsModal } from "@/components/analytics";
import { BreadcrumbLink, Logo } from "@/components/common";
import { HeaderContainer } from "@/components/containers";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
// constants
import {
@@ -161,169 +162,164 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
onClose={() => setAnalyticsModal(false)}
moduleDetails={moduleDetails ?? undefined}
/>
<div className="relative z-[15] items-center gap-x-2 gap-y-4">
<div className="flex justify-between bg-custom-sidebar-background-100 p-4">
<div className="flex items-center gap-2">
<Breadcrumbs onBack={router.back} isLoading={loader}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<span>
<span className="hidden md:block">
<BreadcrumbLink
label={currentProjectDetails?.name ?? "Project"}
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
icon={
currentProjectDetails && (
<span className="grid h-4 w-4 flex-shrink-0 place-items-center">
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
}
/>
</span>
<Link
<HeaderContainer>
<HeaderContainer.LeftItem>
<Breadcrumbs onBack={router.back} isLoading={loader}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<span>
<span className="hidden md:block">
<BreadcrumbLink
label={currentProjectDetails?.name ?? "Project"}
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
className="block pl-2 text-custom-text-300 md:hidden"
>
...
</Link>
icon={
currentProjectDetails && (
<span className="grid h-4 w-4 flex-shrink-0 place-items-center">
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
}
/>
</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={
<CustomMenu
label={
<>
<DiceIcon className="h-3 w-3" />
<div className="flex w-auto max-w-[70px] items-center gap-2 truncate sm:max-w-[200px]">
<p className="truncate">{moduleDetails?.name && moduleDetails.name}</p>
{issuesCount && issuesCount > 0 ? (
<Tooltip
isMobile={isMobile}
tooltipContent={`There are ${issuesCount} ${
issuesCount > 1 ? "issues" : "issue"
} 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">
{issuesCount}
</span>
</Tooltip>
) : null}
</div>
</>
}
className="ml-1.5 flex-shrink-0"
placement="bottom-start"
<Link
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
className="block pl-2 text-custom-text-300 md:hidden"
>
{projectModuleIds?.map((moduleId) => <ModuleDropdownOption key={moduleId} moduleId={moduleId} />)}
</CustomMenu>
}
/>
</Breadcrumbs>
</div>
<div className="flex items-center gap-2">
<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_LAYOUT.issues[activeLayout] : undefined
}
labels={projectLabels}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
...
</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" />}
/>
</FiltersDropdown>
<FiltersDropdown title="Display" placement="bottom-end">
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
/>
<Breadcrumbs.BreadcrumbItem
type="component"
component={
<CustomMenu
label={
<>
<DiceIcon className="h-3 w-3" />
<div className="flex w-auto max-w-[70px] items-center gap-2 truncate sm:max-w-[200px]">
<p className="truncate">{moduleDetails?.name && moduleDetails.name}</p>
{issuesCount && issuesCount > 0 ? (
<Tooltip
isMobile={isMobile}
tooltipContent={`There are ${issuesCount} ${
issuesCount > 1 ? "issues" : "issue"
} 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">
{issuesCount}
</span>
</Tooltip>
) : null}
</div>
</>
}
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"
className="ml-1.5 flex-shrink-0"
placement="bottom-start"
>
Analytics
</Button>
<Button
className="hidden sm:flex"
onClick={() => {
setTrackElement("Module issues page");
toggleCreateIssueModal(true, EIssuesStoreType.MODULE);
}}
size="sm"
>
Add issue
</Button>
</>
)}
<button
type="button"
className="grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80"
onClick={toggleSidebar}
{projectModuleIds?.map((moduleId) => <ModuleDropdownOption key={moduleId} moduleId={moduleId} />)}
</CustomMenu>
}
/>
</Breadcrumbs>
</HeaderContainer.LeftItem>
<HeaderContainer.RightItem>
<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)}
>
<ArrowRight
className={`hidden h-4 w-4 duration-300 md:block ${isSidebarCollapsed ? "-rotate-180" : ""}`}
<FilterSelection
filters={issueFilters?.filters ?? {}}
handleFiltersUpdate={handleFiltersUpdate}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
labels={projectLabels}
memberIds={projectMemberIds ?? undefined}
states={projectStates}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
/>
<PanelRight
className={cn(
"block h-4 w-4 md:hidden",
!isSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200"
)}
</FiltersDropdown>
<FiltersDropdown title="Display" placement="bottom-end">
<DisplayFiltersSelection
layoutDisplayFiltersOptions={
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
ignoreGroupedFilters={["module"]}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
/>
</button>
</FiltersDropdown>
</div>
</div>
</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 issues page");
toggleCreateIssueModal(true, EIssuesStoreType.MODULE);
}}
size="sm"
>
Add issue
</Button>
</>
) : (
<></>
)}
<button
type="button"
className="grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80"
onClick={toggleSidebar}
>
<ArrowRight className={`hidden h-4 w-4 duration-300 md:block ${isSidebarCollapsed ? "-rotate-180" : ""}`} />
<PanelRight
className={cn("block h-4 w-4 md:hidden", !isSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200")}
/>
</button>
</HeaderContainer.RightItem>
</HeaderContainer>
</>
);
});
@@ -6,6 +6,7 @@ import { useParams } from "next/navigation";
import { Breadcrumbs, Button, DiceIcon } from "@plane/ui";
// components
import { BreadcrumbLink, Logo } from "@/components/common";
import { HeaderContainer } from "@/components/containers";
import { ModuleViewHeader } from "@/components/modules";
// constants
import { EUserProjectRoles } from "@/constants/project";
@@ -30,8 +31,8 @@ export const ModulesListHeader: React.FC = observer(() => {
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
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">
<HeaderContainer>
<HeaderContainer.LeftItem>
<div>
<Breadcrumbs onBack={router.back} isLoading={loader}>
<Breadcrumbs.BreadcrumbItem
@@ -56,10 +57,10 @@ export const ModulesListHeader: React.FC = observer(() => {
/>
</Breadcrumbs>
</div>
</div>
<div className="flex items-center gap-2">
</HeaderContainer.LeftItem>
<HeaderContainer.RightItem>
<ModuleViewHeader />
{canUserCreateModule && (
{canUserCreateModule ? (
<Button
variant="primary"
size="sm"
@@ -70,8 +71,10 @@ export const ModulesListHeader: React.FC = observer(() => {
>
<div className="hidden sm:block">Add</div> Module
</Button>
) : (
<></>
)}
</div>
</div>
</HeaderContainer.RightItem>
</HeaderContainer>
);
});
@@ -64,7 +64,7 @@ const PageDetailsPage = observer(() => {
<>
<PageHead title={name} />
<div className="flex h-full flex-col justify-between">
<div className="h-full w-full flex-shrink-0 flex flex-col overflow-hidden">
<div className="relative h-full w-full flex-shrink-0 flex flex-col overflow-hidden">
<PageRoot page={page} projectId={projectId.toString()} workspaceSlug={workspaceSlug.toString()} />
<IssuePeekOverview />
</div>
@@ -2,7 +2,7 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { useParams, useSearchParams } from "next/navigation";
import { FileText } from "lucide-react";
// types
import { TLogoProps } from "@plane/types";
@@ -11,6 +11,7 @@ import { Breadcrumbs, Button, EmojiIconPicker, EmojiIconPickerTypes, TOAST_TYPE,
// components
import { BreadcrumbLink, Logo } from "@/components/common";
// helpers
import { HeaderContainer } from "@/components/containers";
import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper";
// hooks
import { usePage, useProject } from "@/hooks/store";
@@ -25,6 +26,7 @@ export interface IPagesHeaderProps {
export const PageDetailsHeader = observer(() => {
// router
const { workspaceSlug, pageId } = useParams();
const searchParams = useSearchParams();
// state
const [isOpen, setIsOpen] = useState(false);
// store hooks
@@ -55,9 +57,11 @@ export const PageDetailsHeader = observer(() => {
}
};
const isVersionHistoryOverlayActive = !!searchParams.get("version");
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">
<HeaderContainer>
<HeaderContainer.LeftItem>
<div>
<Breadcrumbs isLoading={loader}>
<Breadcrumbs.BreadcrumbItem
@@ -155,27 +159,31 @@ export const PageDetailsHeader = observer(() => {
/>
</Breadcrumbs>
</div>
</div>
<PageDetailsHeaderExtraActions />
{isContentEditable && (
<Button
variant="primary"
size="sm"
onClick={() => {
// ctrl/cmd + s to save the changes
const event = new KeyboardEvent("keydown", {
key: "s",
ctrlKey: !isMac,
metaKey: isMac,
});
window.dispatchEvent(event);
}}
className="flex-shrink-0 w-24"
loading={isSubmitting === "submitting"}
>
{isSubmitting === "submitting" ? "Saving" : "Save changes"}
</Button>
)}
</div>
</HeaderContainer.LeftItem>
<HeaderContainer.RightItem>
<PageDetailsHeaderExtraActions />
{isContentEditable && !isVersionHistoryOverlayActive ? (
<Button
variant="primary"
size="sm"
onClick={() => {
// ctrl/cmd + s to save the changes
const event = new KeyboardEvent("keydown", {
key: "s",
ctrlKey: !isMac,
metaKey: isMac,
});
window.dispatchEvent(event);
}}
className="flex-shrink-0 w-24"
loading={isSubmitting === "submitting"}
>
{isSubmitting === "submitting" ? "Saving" : "Save changes"}
</Button>
) : (
<></>
)}
</HeaderContainer.RightItem>
</HeaderContainer>
);
});
@@ -8,6 +8,7 @@ import { Breadcrumbs, Button } from "@plane/ui";
// helpers
import { BreadcrumbLink, Logo } from "@/components/common";
// constants
import { HeaderContainer } from "@/components/containers";
import { EPageAccess } from "@/constants/page";
import { EUserProjectRoles } from "@/constants/project";
// hooks
@@ -30,8 +31,8 @@ export const PagesListHeader = observer(() => {
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
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">
<HeaderContainer>
<HeaderContainer.LeftItem>
<div>
<Breadcrumbs isLoading={loader}>
<Breadcrumbs.BreadcrumbItem
@@ -56,24 +57,28 @@ export const PagesListHeader = observer(() => {
/>
</Breadcrumbs>
</div>
</div>
{canUserCreatePage && (
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
onClick={() => {
setTrackElement("Project pages page");
toggleCreatePageModal({
isOpen: true,
pageAccess: pageType === "private" ? EPageAccess.PRIVATE : EPageAccess.PUBLIC,
});
}}
>
Add page
</Button>
</div>
)}
</div>
</HeaderContainer.LeftItem>
<HeaderContainer.RightItem>
{canUserCreatePage ? (
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
onClick={() => {
setTrackElement("Project pages page");
toggleCreatePageModal({
isOpen: true,
pageAccess: pageType === "private" ? EPageAccess.PRIVATE : EPageAccess.PUBLIC,
});
}}
>
Add page
</Button>
</div>
) : (
<></>
)}
</HeaderContainer.RightItem>
</HeaderContainer>
);
});
@@ -9,6 +9,7 @@ import { Breadcrumbs, CustomMenu } from "@plane/ui";
// components
import { BreadcrumbLink, Logo } from "@/components/common";
// constants
import { HeaderContainer } from "@/components/containers";
import { EUserProjectRoles } from "@/constants/project";
// hooks
import { useProject, useUser } from "@/hooks/store";
@@ -29,8 +30,8 @@ export const ProjectSettingHeader: FC = observer(() => {
const projectMemberInfo = currentProjectRole || EUserProjectRoles.GUEST;
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">
<HeaderContainer>
<HeaderContainer.LeftItem>
<div>
<div className="z-50">
<Breadcrumbs onBack={router.back} isLoading={loader}>
@@ -84,7 +85,7 @@ export const ProjectSettingHeader: FC = observer(() => {
)
)}
</CustomMenu>
</div>
</div>
</HeaderContainer.LeftItem>
</HeaderContainer>
);
});
@@ -11,6 +11,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption
import { Breadcrumbs, Button, CustomMenu, Tooltip } from "@plane/ui";
// components
import { BreadcrumbLink, Logo } from "@/components/common";
import { HeaderContainer } from "@/components/containers";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
// constants
import {
@@ -136,8 +137,8 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
const publishLink = getPublishViewLink(viewDetails?.anchor);
return (
<div className="relative z-[15] flex h-[3.75rem] w-full items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
<div className="flex items-center gap-2">
<HeaderContainer>
<HeaderContainer.LeftItem>
<Breadcrumbs isLoading={loader}>
<Breadcrumbs.BreadcrumbItem
type="text"
@@ -208,15 +209,17 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
/>
</Breadcrumbs>
{viewDetails?.access === EViewAccess.PRIVATE && (
{viewDetails?.access === EViewAccess.PRIVATE ? (
<div className="cursor-default text-custom-text-300">
<Tooltip tooltipContent={"Private"}>
<Lock className="h-4 w-4" />
</Tooltip>
</div>
) : (
<></>
)}
{viewDetails?.anchor && publishLink && (
{viewDetails?.anchor && publishLink ? (
<a
href={publishLink}
className="px-3 py-1.5 bg-green-500/20 text-green-500 rounded text-xs font-medium flex items-center gap-1.5"
@@ -226,10 +229,12 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
<span className="flex-shrink-0 rounded-full size-1.5 bg-green-500" />
Live
</a>
) : (
<></>
)}
</div>
<div className="flex items-center gap-2">
{!viewDetails?.is_locked && (
</HeaderContainer.LeftItem>
<HeaderContainer.RightItem>
{!viewDetails?.is_locked ? (
<>
<LayoutSelection
layouts={[
@@ -278,8 +283,10 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
/>
</FiltersDropdown>
</>
) : (
<></>
)}
{canUserCreateIssue && (
{canUserCreateIssue ? (
<Button
onClick={() => {
setTrackElement("PROJECT_VIEW_PAGE_HEADER");
@@ -289,8 +296,10 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
>
Add issue
</Button>
) : (
<></>
)}
</div>
</div>
</HeaderContainer.RightItem>
</HeaderContainer>
);
});
@@ -9,6 +9,7 @@ import { TViewFilterProps } from "@plane/types";
import { Breadcrumbs, Button } from "@plane/ui";
// components
import { BreadcrumbLink, Logo } from "@/components/common";
import { HeaderContainer } from "@/components/containers";
import { ViewListHeader } from "@/components/views";
import { ViewAppliedFiltersList } from "@/components/views/applied-filters";
// constants
@@ -47,44 +48,42 @@ export const ProjectViewsHeader = observer(() => {
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>
<Breadcrumbs isLoading={loader}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
label={currentProjectDetails?.name ?? "Project"}
icon={
currentProjectDetails && (
<span className="grid h-4 w-4 flex-shrink-0 place-items-center">
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={<BreadcrumbLink label="Views" icon={<Layers className="h-4 w-4 text-custom-text-300" />} />}
/>
</Breadcrumbs>
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<HeaderContainer>
<HeaderContainer.LeftItem>
<Breadcrumbs isLoading={loader}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
label={currentProjectDetails?.name ?? "Project"}
icon={
currentProjectDetails && (
<span className="grid h-4 w-4 flex-shrink-0 place-items-center">
<Logo logo={currentProjectDetails?.logo_props} size={16} />
</span>
)
}
/>
}
/>
<Breadcrumbs.BreadcrumbItem
type="text"
link={<BreadcrumbLink label="Views" icon={<Layers className="h-4 w-4 text-custom-text-300" />} />}
/>
</Breadcrumbs>
</HeaderContainer.LeftItem>
<HeaderContainer.RightItem>
<ViewListHeader />
<div>
<Button variant="primary" size="sm" onClick={() => toggleCreateViewModal(true)}>
Add view
</Button>
</div>
</div>
</div>
</HeaderContainer.RightItem>
</HeaderContainer>
{isFiltersApplied && (
<div className="border-t border-custom-border-200 px-5 py-3">
<div className="border-t border-custom-border-200 p-page-x py-3">
<ViewAppliedFiltersList
appliedFilters={filters.filters ?? {}}
handleClearAllFilters={clearAllFilters}
@@ -8,30 +8,29 @@ import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
// hooks
import { HeaderContainer } from "@/components/containers";
import { useWorkspace } from "@/hooks/store";
export const WorkspaceSettingHeader: FC = observer(() => {
const { currentWorkspace, loader } = useWorkspace();
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>
<Breadcrumbs isLoading={loader}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${currentWorkspace?.slug}/settings`}
label={currentWorkspace?.name ?? "Workspace"}
icon={<Settings className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.BreadcrumbItem type="text" link={<BreadcrumbLink label="Settings" />} />
</Breadcrumbs>
</div>
</div>
</div>
<HeaderContainer>
<HeaderContainer.LeftItem>
<Breadcrumbs isLoading={loader}>
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href={`/${currentWorkspace?.slug}/settings`}
label={currentWorkspace?.name ?? "Workspace"}
icon={<Settings className="h-4 w-4 text-custom-text-300" />}
/>
}
/>
<Breadcrumbs.BreadcrumbItem type="text" link={<BreadcrumbLink label="Settings" />} />
</Breadcrumbs>
</HeaderContainer.LeftItem>
</HeaderContainer>
);
});
@@ -10,6 +10,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption
import { Breadcrumbs, Button } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
import { HeaderContainer } from "@/components/containers";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection } from "@/components/issues";
import { CreateUpdateWorkspaceViewModal } from "@/components/workspace";
// constants
@@ -98,17 +99,18 @@ export const GlobalIssuesHeader = observer(() => {
return (
<>
<CreateUpdateWorkspaceViewModal isOpen={createViewModal} onClose={() => setCreateViewModal(false)} />
<div className="relative z-[15] flex h-[3.75rem] w-full items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
<div className="relative flex gap-2">
<HeaderContainer>
<HeaderContainer.LeftItem>
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
type="text"
link={<BreadcrumbLink label={`Views`} icon={<Layers className="h-4 w-4 text-custom-text-300" />} />}
/>
</Breadcrumbs>
</div>
<div className="flex items-center gap-2">
{!isLocked && (
</HeaderContainer.LeftItem>
<HeaderContainer.RightItem>
{!isLocked ? (
<>
<FiltersDropdown
title="Filters"
@@ -135,13 +137,15 @@ export const GlobalIssuesHeader = observer(() => {
/>
</FiltersDropdown>
</>
) : (
<></>
)}
<Button variant="primary" size="sm" onClick={() => setCreateViewModal(true)}>
Add view
</Button>
</div>
</div>
</HeaderContainer.RightItem>
</HeaderContainer>
</>
);
});
+10 -5
View File
@@ -1,4 +1,4 @@
import { Metadata } from "next";
import { Metadata, Viewport } from "next";
import Script from "next/script";
// styles
import "@/styles/globals.css";
@@ -28,6 +28,15 @@ export const metadata: Metadata = {
},
};
export const viewport: Viewport = {
minimumScale: 1,
initialScale: 1,
maximumScale: 1,
userScalable: false,
width: "device-width",
viewportFit: "cover",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
const isSessionRecorderEnabled = parseInt(process.env.NEXT_PUBLIC_ENABLE_SESSION_RECORDER || "0");
@@ -51,10 +60,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<link rel="apple-touch-icon" sizes="180x180" href="/icons/icon-180x180.png" />
<link rel="apple-touch-icon" sizes="512x512" href="/icons/icon-512x512.png" />
<link rel="manifest" href="/manifest.json" />
<meta
name="viewport"
content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no, user-scalable=no, viewport-fit=cover"
/>
{/* preloading */}
<link rel="preload" href={`${API_BASE_URL}/api/instances/`} as="fetch" crossOrigin="use-credentials" />
<link rel="preload" href={`${API_BASE_URL}/api/users/me/ `} as="fetch" crossOrigin="use-credentials" />
@@ -15,19 +15,33 @@ type TProPiceFrequency = "month" | "year";
type TProPlanPrice = {
key: string;
price: string;
currency: string;
price: number;
recurring: TProPiceFrequency;
};
// constants
export const calculateYearlyDiscount = (monthlyPrice: number, yearlyPricePerMonth: number): number => {
const monthlyCost = monthlyPrice * 12;
const yearlyCost = yearlyPricePerMonth * 12;
const amountSaved = monthlyCost - yearlyCost;
const discountPercentage = (amountSaved / monthlyCost) * 100;
return Math.floor(discountPercentage);
};
const PRO_PLAN_PRICES: TProPlanPrice[] = [
{ key: "monthly", price: "$7", recurring: "month" },
{ key: "yearly", price: "$5", recurring: "year" },
{ key: "monthly", currency: "$", price: 8, recurring: "month" },
{ key: "yearly", currency: "$", price: 6, recurring: "year" },
];
export const ProPlanUpgrade: FC<ProPlanUpgradeProps> = (props) => {
const { basePlan, features, verticalFeatureList = false, extraFeatures } = props;
// states
const [selectedPlan, setSelectedPlan] = useState<TProPiceFrequency>("month");
// derived
const monthlyPrice = PRO_PLAN_PRICES.find((price) => price.recurring === "month")?.price ?? 0;
const yearlyPrice = PRO_PLAN_PRICES.find((price) => price.recurring === "year")?.price ?? 0;
const yearlyDiscount = calculateYearlyDiscount(monthlyPrice, yearlyPrice);
// env
const PRO_PLAN_MONTHLY_PAYMENT_URL = process.env.NEXT_PUBLIC_PRO_PLAN_MONTHLY_PAYMENT_URL ?? "https://plane.so/pro";
const PRO_PLAN_YEARLY_PAYMENT_URL = process.env.NEXT_PUBLIC_PRO_PLAN_YEARLY_PAYMENT_URL ?? "https://plane.so/pro";
@@ -55,7 +69,7 @@ export const ProPlanUpgrade: FC<ProPlanUpgradeProps> = (props) => {
{price.recurring === "year" && ("Yearly" as string)}
{price.recurring === "year" && (
<span className="bg-gradient-to-r from-[#C78401] to-[#896828] text-white rounded-full px-2 py-1 ml-1 text-xs">
-28%
-{yearlyDiscount}%
</span>
)}
</>
@@ -69,8 +83,8 @@ export const ProPlanUpgrade: FC<ProPlanUpgradeProps> = (props) => {
<div className="pt-6 pb-4 text-center font-semibold">
<div className="text-2xl">Plane Pro</div>
<div className="text-3xl">
{price.recurring === "month" && "$7"}
{price.recurring === "year" && "$5"}
{price.currency}
{price.price}
</div>
<div className="text-sm text-custom-text-300">a user per month</div>
</div>
@@ -1,6 +1,7 @@
// types
import { IDefaultAnalyticsResponse, TStateGroups } from "@plane/types";
// constants
import { BoxContainer } from "@/components/containers";
import { STATE_GROUPS } from "@/constants/state";
type Props = {
@@ -8,7 +9,7 @@ type Props = {
};
export const AnalyticsDemand: React.FC<Props> = ({ defaultAnalytics }) => (
<div className="space-y-3 rounded-[10px] border border-custom-border-200 p-3">
<BoxContainer>
<div>
<h4 className="text-base font-medium text-custom-text-100">Total open tasks</h4>
<h3 className="mt-1 text-xl font-semibold">{defaultAnalytics.open_issues}</h3>
@@ -47,5 +48,5 @@ export const AnalyticsDemand: React.FC<Props> = ({ defaultAnalytics }) => (
);
})}
</div>
</div>
</BoxContainer>
);
@@ -1,4 +1,5 @@
// ui
import { BoxContainer } from "@/components/containers";
import { ProfileEmptyState } from "@/components/ui";
// image
import emptyUsers from "@/public/empty-state/empty_users.svg";
@@ -18,7 +19,7 @@ type Props = {
};
export const AnalyticsLeaderBoard: React.FC<Props> = ({ users, title, emptyStateMessage, workspaceSlug }) => (
<div className="rounded-[10px] border border-custom-border-200 p-3">
<BoxContainer>
<h6 className="text-base font-medium">{title}</h6>
{users.length > 0 ? (
<div className="mt-3 space-y-3">
@@ -57,5 +58,5 @@ export const AnalyticsLeaderBoard: React.FC<Props> = ({ users, title, emptyState
<ProfileEmptyState title="No Data yet" description={emptyStateMessage} image={emptyUsers} />
</div>
)}
</div>
</BoxContainer>
);
@@ -1,9 +1,9 @@
// ui
import { IDefaultAnalyticsUser } from "@plane/types";
import { BoxContainer } from "@/components/containers";
import { BarGraph, ProfileEmptyState } from "@/components/ui";
// image
import emptyBarGraph from "@/public/empty-state/empty_bar_graph.svg";
// types
type Props = {
pendingUnAssignedIssuesUser: IDefaultAnalyticsUser | undefined;
@@ -11,7 +11,7 @@ type Props = {
};
export const AnalyticsScope: React.FC<Props> = ({ pendingUnAssignedIssuesUser, pendingAssignedIssues }) => (
<div className="rounded-[10px] border border-custom-border-200 p-3">
<BoxContainer>
<div className="divide-y divide-custom-border-200">
<div>
<div className="flex items-center justify-between">
@@ -87,5 +87,5 @@ export const AnalyticsScope: React.FC<Props> = ({ pendingUnAssignedIssuesUser, p
)}
</div>
</div>
</div>
</BoxContainer>
);
@@ -1,5 +1,6 @@
// ui
import { IDefaultAnalyticsResponse } from "@plane/types";
import { BoxContainer } from "@/components/containers";
import { LineGraph, ProfileEmptyState } from "@/components/ui";
// image
import { MONTHS_LIST } from "@/constants/calendar";
@@ -12,8 +13,8 @@ type Props = {
};
export const AnalyticsYearWiseIssues: React.FC<Props> = ({ defaultAnalytics }) => (
<div className="rounded-[10px] border border-custom-border-200 py-3">
<h1 className="px-3 text-base font-medium">Issues closed in a year</h1>
<BoxContainer>
<h1 className="text-base font-medium">Issues closed in a year</h1>
{defaultAnalytics.issue_completed_month_wise.length > 0 ? (
<LineGraph
data={[
@@ -55,5 +56,5 @@ export const AnalyticsYearWiseIssues: React.FC<Props> = ({ defaultAnalytics }) =
/>
</div>
)}
</div>
</BoxContainer>
);
@@ -0,0 +1,17 @@
import { cn } from "@plane/editor";
type Props = {
children: React.ReactNode;
className?: string;
};
export const BoxContainer = ({ children, className = "" }: Props) => (
<div
className={cn(
`bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full hover:shadow-custom-shadow-4xl duration-300 p-6`,
className
)}
>
{children}
</div>
);
@@ -0,0 +1,19 @@
interface Props {
children: string | JSX.Element | JSX.Element[];
}
const HeaderContainer = ({ children }: Props) => (
<div className="relative z-10 flex w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 !bg-blue-200">
{children}
</div>
);
const LeftItem = ({ children }: Props) => (
<div className="flex flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">{children}</div>
);
const RightItem = ({ children }: Props) => <div className="w-full flex items-center justify-end gap-3">{children}</div>;
HeaderContainer.LeftItem = LeftItem;
HeaderContainer.RightItem = RightItem;
HeaderContainer.displayName = "core-header-container";
export { HeaderContainer };
@@ -0,0 +1,9 @@
import { cn } from "@plane/editor";
type Props = {
children: string | JSX.Element | JSX.Element[];
className?: string;
};
export const HuggingRow = ({ children, className }: Props) => (
<div className={cn("px-0 py-page-y h-full w-full", className)}>{children}</div>
);
+5
View File
@@ -0,0 +1,5 @@
export * from "./header-container";
export * from "./page-container";
export * from "./box-container";
export * from "./regular-row";
export * from "./hugging-row";
@@ -0,0 +1,16 @@
import { cn } from "@plane/editor";
type Props = {
children: React.ReactNode;
className?: string;
};
export const PageContainer = ({ children, className = "" }: Props) => (
<div
className={cn(
"px-page-x py-page-y h-full w-full flex flex-col space-y-7 overflow-y-auto vertical-scrollbar scrollbar-md",
className
)}
>
{children}
</div>
);
@@ -0,0 +1,9 @@
import { cn } from "@plane/editor";
type Props = {
children: string | JSX.Element | JSX.Element[];
className?: string;
};
export const RegularRow = ({ children, className }: Props) => (
<div className={cn("px-page-x py-page-y w-full", className)}>{children}</div>
);
+9 -10
View File
@@ -3,6 +3,7 @@
import { ReactNode } from "react";
// components
import { SidebarHamburgerToggle } from "@/components/core";
import { RegularRow } from "../containers";
export interface AppHeaderProps {
header: ReactNode;
@@ -13,16 +14,14 @@ export const AppHeader = (props: AppHeaderProps) => {
const { header, mobileHeader } = props;
return (
<>
<div className="z-[15]">
<div className="z-10 flex w-full items-center border-b border-custom-border-200">
<div className="block bg-custom-sidebar-background-100 py-4 pl-5 md:hidden">
<SidebarHamburgerToggle />
</div>
<div className="w-full">{header}</div>
<div className="z-[15] !bg-blue-200">
<RegularRow className="h-[3.75rem] z-10 flex gap-2 w-full items-center border-b border-custom-border-200">
<div className="block bg-custom-sidebar-background-100 md:hidden">
<SidebarHamburgerToggle />
</div>
{mobileHeader && mobileHeader}
</div>
</>
<div className="w-full">{header}</div>
</RegularRow>
{mobileHeader && mobileHeader}
</div>
);
};
@@ -4,6 +4,7 @@ import Link from "next/link";
import { Tab } from "@headlessui/react";
import { TAssignedIssuesWidgetFilters, TAssignedIssuesWidgetResponse } from "@plane/types";
// hooks
import { BoxContainer } from "@/components/containers";
import {
DurationFilterDropdown,
IssuesErrorState,
@@ -79,7 +80,7 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
if ((!widgetDetails || !widgetStats) && !widgetStatsError) return <WidgetLoader widgetKey={WIDGET_KEY} />;
return (
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full hover:shadow-custom-shadow-4xl duration-300 flex flex-col min-h-96">
<BoxContainer className="flex flex-col min-h-96">
{widgetStatsError ? (
<IssuesErrorState
isRefreshing={fetching}
@@ -93,7 +94,7 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
) : (
widgetStats && (
<>
<div className="flex items-center justify-between gap-2 p-6 pl-7">
<div className="flex items-center justify-between gap-2 pb-6">
<Link
href={`/${workspaceSlug}/workspace-views/assigned/${filterParams}`}
className="text-lg font-semibold text-custom-text-300 hover:underline"
@@ -137,9 +138,7 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
}}
className="h-full flex flex-col"
>
<div className="px-6">
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
</div>
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
<Tab.Panels as="div" className="h-full">
{tabsList.map((tab) => {
if (tab.key !== selectedTab) return null;
@@ -161,6 +160,6 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
</>
)
)}
</div>
</BoxContainer>
);
});
@@ -4,6 +4,7 @@ import Link from "next/link";
import { Tab } from "@headlessui/react";
import { TCreatedIssuesWidgetFilters, TCreatedIssuesWidgetResponse } from "@plane/types";
// hooks
import { BoxContainer } from "@/components/containers";
import {
DurationFilterDropdown,
IssuesErrorState,
@@ -76,7 +77,7 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
if ((!widgetDetails || !widgetStats) && !widgetStatsError) return <WidgetLoader widgetKey={WIDGET_KEY} />;
return (
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full hover:shadow-custom-shadow-4xl duration-300 flex flex-col min-h-96">
<BoxContainer className="flex flex-col min-h-96">
{widgetStatsError ? (
<IssuesErrorState
isRefreshing={fetching}
@@ -90,7 +91,7 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
) : (
widgetStats && (
<>
<div className="flex items-center justify-between gap-2 p-6 pl-7">
<div className="flex items-center justify-between gap-2 pb-6">
<Link
href={`/${workspaceSlug}/workspace-views/created/${filterParams}`}
className="text-lg font-semibold text-custom-text-300 hover:underline"
@@ -134,9 +135,7 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
}}
className="h-full flex flex-col"
>
<div className="px-6">
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
</div>
<TabsList durationFilter={selectedDurationFilter} selectedTab={selectedTab} />
<Tab.Panels as="div" className="h-full">
{tabsList.map((tab) => {
if (tab.key !== selectedTab) return null;
@@ -158,6 +157,6 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
</>
)
)}
</div>
</BoxContainer>
);
});
@@ -4,6 +4,7 @@ import Link from "next/link";
// types
import { TIssuesByPriorityWidgetFilters, TIssuesByPriorityWidgetResponse } from "@plane/types";
// components
import { BoxContainer } from "@/components/containers";
import {
DurationFilterDropdown,
IssuesByPriorityEmptyState,
@@ -69,8 +70,8 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
}));
return (
<div className="flex min-h-96 w-full flex-col overflow-hidden rounded-xl border-[0.5px] border-custom-border-200 bg-custom-background-100 py-6 duration-300 hover:shadow-custom-shadow-4xl">
<div className="flex items-center justify-between gap-2 pl-7 pr-6">
<BoxContainer className="flex min-h-96 w-full flex-col overflow-hidden">
<div className="flex items-center justify-between gap-2">
<Link
href={`/${workspaceSlug}/workspace-views/assigned`}
className="text-lg font-semibold text-custom-text-300 hover:underline"
@@ -106,6 +107,6 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
<IssuesByPriorityEmptyState />
</div>
)}
</div>
</BoxContainer>
);
});
@@ -4,6 +4,7 @@ import Link from "next/link";
// types
import { TIssuesByStateGroupsWidgetFilters, TIssuesByStateGroupsWidgetResponse, TStateGroups } from "@plane/types";
// components
import { BoxContainer } from "@/components/containers";
import {
DurationFilterDropdown,
IssuesByStateGroupEmptyState,
@@ -79,14 +80,14 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
startedCount > 0
? "started"
: unStartedCount > 0
? "unstarted"
: backlogCount > 0
? "backlog"
: completedCount > 0
? "completed"
: canceledCount > 0
? "cancelled"
: null;
? "unstarted"
: backlogCount > 0
? "backlog"
: completedCount > 0
? "completed"
: canceledCount > 0
? "cancelled"
: null;
setActiveStateGroup(stateGroup);
setDefaultStateGroup(stateGroup);
@@ -134,8 +135,8 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
};
return (
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full py-6 hover:shadow-custom-shadow-4xl duration-300 overflow-hidden min-h-96 flex flex-col">
<div className="flex items-center justify-between gap-2 pl-7 pr-6">
<BoxContainer className="overflow-hidden min-h-96 flex flex-col">
<div className="flex items-center justify-between gap-2">
<Link
href={`/${workspaceSlug}/workspace-views/assigned`}
className="text-lg font-semibold text-custom-text-300 hover:underline"
@@ -154,7 +155,7 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
/>
</div>
{totalCount > 0 ? (
<div className="flex items-center pl-10 md:pl-11 lg:pl-14 pr-11 mt-11">
<div className="flex items-center mt-11">
<div className="flex flex-col sm:flex-row md:flex-row lg:flex-row items-center justify-evenly gap-x-10 gap-y-8 w-full">
<div>
<PieGraph
@@ -215,6 +216,6 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
<IssuesByStateGroupEmptyState />
</div>
)}
</div>
</BoxContainer>
);
});
@@ -3,6 +3,7 @@ import { observer } from "mobx-react";
import Link from "next/link";
import { TOverviewStatsWidgetResponse } from "@plane/types";
// hooks
import { BoxContainer } from "@/components/containers";
import { WidgetLoader } from "@/components/dashboard/widgets";
import { cn } from "@/helpers/common.helper";
import { renderFormattedPayloadDate } from "@/helpers/date-time.helper";
@@ -63,8 +64,8 @@ export const OverviewStatsWidget: React.FC<WidgetProps> = observer((props) => {
if (!widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
return (
<div
className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full grid lg:grid-cols-4 md:grid-cols-2 sm:grid-cols-2 grid-cols-2 p-0.5 hover:shadow-custom-shadow-4xl duration-300
<BoxContainer
className="w-full grid lg:grid-cols-4 md:grid-cols-2 sm:grid-cols-2 grid-cols-2 p-0
[&>div>a>div]:border-r
[&>div:last-child>a>div]:border-0
[&>div>a>div]:border-custom-border-200
@@ -93,6 +94,6 @@ export const OverviewStatsWidget: React.FC<WidgetProps> = observer((props) => {
</Link>
</div>
))}
</div>
</BoxContainer>
);
});
@@ -9,6 +9,7 @@ import { TRecentActivityWidgetResponse } from "@plane/types";
// UI
import { Avatar, getButtonStyling } from "@plane/ui";
// components
import { BoxContainer } from "@/components/containers";
import { ActivityIcon, ActivityMessage, IssueLink } from "@/components/core";
import { RecentActivityEmptyState, WidgetLoader, WidgetProps } from "@/components/dashboard/widgets";
// helpers
@@ -38,12 +39,12 @@ export const RecentActivityWidget: React.FC<WidgetProps> = observer((props) => {
if (!widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
return (
<div className="min-h-96 w-full rounded-xl border-[0.5px] border-custom-border-200 bg-custom-background-100 py-6 duration-300 hover:shadow-custom-shadow-4xl">
<Link href={redirectionLink} className="mx-7 text-lg font-semibold text-custom-text-300 hover:underline">
<BoxContainer className="min-h-96 w-full">
<Link href={redirectionLink} className="text-lg font-semibold text-custom-text-300 hover:underline">
Your issue activities
</Link>
{widgetStats.length > 0 ? (
<div className="mx-7 mt-4 space-y-6">
<div className="mt-4 space-y-6">
{widgetStats.map((activity) => (
<div key={activity.id} className="flex gap-5">
<div className="flex-shrink-0">
@@ -104,6 +105,6 @@ export const RecentActivityWidget: React.FC<WidgetProps> = observer((props) => {
<RecentActivityEmptyState />
</div>
)}
</div>
</BoxContainer>
);
});
@@ -1,6 +1,7 @@
import { useState } from "react";
import { Search } from "lucide-react";
// types
import { BoxContainer } from "@/components/containers";
import { WidgetProps } from "@/components/dashboard/widgets";
// components
import { DefaultCollaboratorsList } from "./default-list";
@@ -14,8 +15,8 @@ export const RecentCollaboratorsWidget: React.FC<WidgetProps> = (props) => {
const [searchQuery, setSearchQuery] = useState("");
return (
<div className="w-full rounded-xl border-[0.5px] border-custom-border-200 bg-custom-background-100 duration-300 hover:shadow-custom-shadow-4xl">
<div className="flex flex-col sm:flex-row items-start justify-between px-7 pt-6">
<BoxContainer className="w-full">
<div className="flex flex-col sm:flex-row items-start justify-between">
<div>
<h4 className="text-lg font-semibold text-custom-text-300">Collaborators</h4>
<p className="mt-2 text-xs font-medium text-custom-text-300">
@@ -42,6 +43,6 @@ export const RecentCollaboratorsWidget: React.FC<WidgetProps> = (props) => {
) : (
<DefaultCollaboratorsList dashboardId={dashboardId} perPage={PER_PAGE} workspaceSlug={workspaceSlug} />
)}
</div>
</BoxContainer>
);
};
@@ -10,6 +10,7 @@ import { TRecentProjectsWidgetResponse } from "@plane/types";
import { Avatar, AvatarGroup } from "@plane/ui";
// components
import { Logo } from "@/components/common";
import { BoxContainer } from "@/components/containers";
import { WidgetLoader, WidgetProps } from "@/components/dashboard/widgets";
// constants
import { PROJECT_BACKGROUND_COLORS } from "@/constants/dashboard";
@@ -82,14 +83,11 @@ export const RecentProjectsWidget: React.FC<WidgetProps> = observer((props) => {
if (!widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
return (
<div className="min-h-96 w-full rounded-xl border-[0.5px] border-custom-border-200 bg-custom-background-100 py-6 duration-300 hover:shadow-custom-shadow-4xl">
<Link
href={`/${workspaceSlug}/projects`}
className="mx-7 text-lg font-semibold text-custom-text-300 hover:underline"
>
<BoxContainer className="min-h-96 w-full">
<Link href={`/${workspaceSlug}/projects`} className="text-lg font-semibold text-custom-text-300 hover:underline">
Recent projects
</Link>
<div className="mx-7 mt-4 space-y-8">
<div className="mt-4 space-y-8">
{canCreateProject && (
<button
type="button"
@@ -113,6 +111,6 @@ export const RecentProjectsWidget: React.FC<WidgetProps> = observer((props) => {
<ProjectListItem key={projectId} projectId={projectId} workspaceSlug={workspaceSlug} />
))}
</div>
</div>
</BoxContainer>
);
});
+143
View File
@@ -0,0 +1,143 @@
"use client";
import { useCallback, useState } from "react";
import { TProject } from "ee/types";
// types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
// ui
import { Button } from "@plane/ui";
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
// constants
import {
EIssueFilterType,
EIssuesStoreType,
EIssueLayoutTypes,
ISSUE_DISPLAY_FILTERS_BY_LAYOUT,
} from "@/constants/issue";
// helpers
import { isIssueFilterActive } from "@/helpers/filter.helper";
// hooks
import { useLabel, useProjectState, useMember, useIssues } from "@/hooks/store";
import { ProjectAnalyticsModal } from "../analytics";
type Props = {
currentProjectDetails: TProject | undefined;
projectId: string;
workspaceSlug: string;
canUserCreateIssue: boolean | undefined;
};
const HeaderFilters = ({ currentProjectDetails, projectId, workspaceSlug, canUserCreateIssue }: Props) => {
// states
const [analyticsModal, setAnalyticsModal] = useState(false);
// store hooks
const {
project: { projectMemberIds },
} = useMember();
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.PROJECT);
const { projectStates } = useProjectState();
const { projectLabels } = useLabel();
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]
);
return (
<>
<ProjectAnalyticsModal
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
projectDetails={currentProjectDetails ?? undefined}
/>
<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_LAYOUT.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_LAYOUT.issues[activeLayout] : undefined}
displayFilters={issueFilters?.displayFilters ?? {}}
handleDisplayFiltersUpdate={handleDisplayFilters}
displayProperties={issueFilters?.displayProperties ?? {}}
handleDisplayPropertiesUpdate={handleDisplayProperties}
cycleViewDisabled={!currentProjectDetails?.cycle_view}
moduleViewDisabled={!currentProjectDetails?.module_view}
/>
</FiltersDropdown>
{canUserCreateIssue ? (
<Button className="hidden md:block" onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
Analytics
</Button>
) : (
<></>
)}
</>
);
};
export default HeaderFilters;
@@ -7,9 +7,10 @@ import { LiteTextEditor } from "@/components/editor/lite-text-editor/lite-text-e
// constants
import { EIssueCommentAccessSpecifier } from "@/constants/issue";
// helpers
import { cn } from "@/helpers/common.helper";
import { isEmptyHtmlString } from "@/helpers/string.helper";
// hooks
import { useWorkspace } from "@/hooks/store";
import { useIssueDetail, useWorkspace } from "@/hooks/store";
// editor
import { TActivityOperations } from "../root";
@@ -27,6 +28,7 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
const editorRef = useRef<any>(null);
// store hooks
const workspaceStore = useWorkspace();
const { peekIssue } = useIssueDetail();
// derived values
const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string;
// form info
@@ -58,6 +60,9 @@ export const IssueCommentCreate: FC<TIssueCommentCreate> = (props) => {
return (
<div
className={cn("sticky bottom-0 z-10 bg-custom-background-100 sm:static", {
"-bottom-5": !peekIssue,
})}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !isEmpty && !isSubmitting)
handleSubmit(onSubmit)(e);
@@ -25,6 +25,7 @@ import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web constants
import { EEstimateSystem } from "@/plane-web/constants/estimates";
import { BoxContainer } from "../containers";
type Props = {
moduleId: string;
@@ -176,7 +177,7 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
return (
<div className="relative">
<Link ref={parentRef} href={`/${workspaceSlug}/projects/${moduleDetails.project_id}/modules/${moduleDetails.id}`}>
<div className="flex h-44 w-full flex-col justify-between rounded border border-custom-border-100 bg-custom-background-100 p-4 text-sm hover:shadow-md">
<BoxContainer className="flex h-44 flex-col justify-between rounded">
<div>
<div className="flex items-center justify-between gap-2">
<Tooltip tooltipContent={moduleDetails.name} position="top" isMobile={isMobile}>
@@ -228,11 +229,11 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
<span className="flex-grow truncate">{renderFormattedDate(endDate)}</span>
</div>
) : (
<span className="text-xs text-custom-text-400">No due date</span>
<span className="text-xs text-custom-text-400">No due ddate</span>
)}
</div>
</div>
</div>
</BoxContainer>
</Link>
<div className="absolute right-4 bottom-[18px] flex items-center gap-1.5">
{isEditingAllowed && (
@@ -1,8 +1,8 @@
"use client";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { ArchiveRestoreIcon, Clipboard, Copy, Link, Lock, LockOpen } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { ArchiveRestoreIcon, Clipboard, Copy, History, Link, Lock, LockOpen } from "lucide-react";
// document editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
// ui
@@ -11,6 +11,7 @@ import { ArchiveIcon, CustomMenu, TOAST_TYPE, ToggleSwitch, setToast } from "@pl
import { copyTextToClipboard, copyUrlToClipboard } from "@/helpers/string.helper";
// hooks
import { usePageFilters } from "@/hooks/use-page-filters";
import { useQueryParams } from "@/hooks/use-query-params";
// store
import { IPage } from "@/store/pages/page";
@@ -23,6 +24,8 @@ type Props = {
export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
const { editorRef, handleDuplicatePage, page, handleSaveDescription } = props;
// router
const router = useRouter();
// store values
const {
archived_at,
@@ -40,6 +43,8 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
const { workspaceSlug, projectId } = useParams();
// page filters
const { isFullWidth, handleFullWidth } = usePageFilters();
// update query params
const { updateQueryParams } = useQueryParams();
const handleArchivePage = async () =>
await archive().catch(() =>
@@ -145,6 +150,19 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
icon: archived_at ? ArchiveRestoreIcon : ArchiveIcon,
shouldRender: canCurrentUserArchivePage,
},
{
key: "version-history",
action: () => {
// add query param, version=current to the route
const updatedRoute = updateQueryParams({
paramsToAdd: { version: "current" },
});
router.push(updatedRoute);
},
label: "Version history",
icon: History,
shouldRender: true,
},
];
return (
+77 -20
View File
@@ -1,12 +1,23 @@
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
// plane editor
import { EditorRefApi, useEditorMarkings } from "@plane/editor";
// plane types
import { TPage } from "@plane/types";
// plane ui
import { setToast, TOAST_TYPE } from "@plane/ui";
import { PageEditorHeaderRoot, PageEditorBody } from "@/components/pages";
// components
import { PageEditorHeaderRoot, PageEditorBody, PageVersionsOverlay } from "@/components/pages";
// hooks
import { useProjectPages } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePageDescription } from "@/hooks/use-page-description";
import { useQueryParams } from "@/hooks/use-query-params";
// services
import { ProjectPageVersionService } from "@/services/page";
const projectPageVersionService = new ProjectPageVersionService();
// store
import { IPage } from "@/store/pages/page";
type TPageRootProps = {
@@ -16,34 +27,40 @@ type TPageRootProps = {
};
export const PageRoot = observer((props: TPageRootProps) => {
// router
const router = useAppRouter();
const { projectId, workspaceSlug, page } = props;
const { createPage } = useProjectPages();
const { access, description_html, name } = page;
// states
const [editorReady, setEditorReady] = useState(false);
const [readOnlyEditorReady, setReadOnlyEditorReady] = useState(false);
const [sidePeekVisible, setSidePeekVisible] = useState(window.innerWidth >= 768);
const [isVersionsOverlayOpen, setIsVersionsOverlayOpen] = useState(false);
// refs
const editorRef = useRef<EditorRefApi>(null);
const readOnlyEditorRef = useRef<EditorRefApi>(null);
// router
const router = useAppRouter();
// search params
const searchParams = useSearchParams();
// store hooks
const { createPage } = useProjectPages();
// derived values
const { access, description_html, name } = page;
// editor markings hook
const { markings, updateMarkings } = useEditorMarkings();
const [sidePeekVisible, setSidePeekVisible] = useState(window.innerWidth >= 768 ? true : false);
// project-description
const { handleDescriptionChange, isDescriptionReady, pageDescriptionYJS, handleSaveDescription } = usePageDescription(
{
editorRef,
page,
projectId,
workspaceSlug,
}
);
const {
handleDescriptionChange,
isDescriptionReady,
pageDescriptionYJS,
handleSaveDescription,
manuallyUpdateDescription,
} = usePageDescription({
editorRef,
page,
projectId,
workspaceSlug,
});
// update query params
const { updateQueryParams } = useQueryParams();
const handleCreatePage = async (payload: Partial<TPage>) => await createPage(payload);
@@ -65,8 +82,48 @@ export const PageRoot = observer((props: TPageRootProps) => {
);
};
const version = searchParams.get("version");
useEffect(() => {
if (!version) {
setIsVersionsOverlayOpen(false);
return;
}
setIsVersionsOverlayOpen(true);
}, [version]);
const handleCloseVersionsOverlay = () => {
const updatedRoute = updateQueryParams({
paramsToRemove: ["version"],
});
router.push(updatedRoute);
};
return (
<>
<PageVersionsOverlay
activeVersion={version}
fetchAllVersions={async (pageId) => {
if (!workspaceSlug || !projectId) return;
return await projectPageVersionService.fetchAllVersions(
workspaceSlug.toString(),
projectId.toString(),
pageId
);
}}
fetchVersionDetails={async (pageId, versionId) => {
if (!workspaceSlug || !projectId) return;
return await projectPageVersionService.fetchVersionById(
workspaceSlug.toString(),
projectId.toString(),
pageId,
versionId
);
}}
handleRestore={manuallyUpdateDescription}
isOpen={isVersionsOverlayOpen}
onClose={handleCloseVersionsOverlay}
pageId={page.id ?? ""}
/>
<PageEditorHeaderRoot
editorRef={editorRef}
readOnlyEditorRef={readOnlyEditorRef}
+1
View File
@@ -4,5 +4,6 @@ export * from "./header";
export * from "./list";
export * from "./loaders";
export * from "./modals";
export * from "./version";
export * from "./pages-list-main-content";
export * from "./pages-list-view";
@@ -0,0 +1,111 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane editor
import { DocumentReadOnlyEditorWithRef, TDisplayConfig } from "@plane/editor";
// plane types
import { IUserLite, TPageVersion } from "@plane/types";
// plane ui
import { Loader } from "@plane/ui";
// hooks
import { useMember, useMention, usePage, useUser } from "@/hooks/store";
import { usePageFilters } from "@/hooks/use-page-filters";
// plane web hooks
import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed";
type Props = {
activeVersion: string | null;
isCurrentVersionActive: boolean;
versionDetails: TPageVersion | undefined;
};
export const PagesVersionEditor: React.FC<Props> = observer((props) => {
const { activeVersion, isCurrentVersionActive, versionDetails } = props;
// params
const { workspaceSlug, projectId, pageId } = useParams();
// store hooks
const { data: currentUser } = useUser();
const {
getUserDetails,
project: { getProjectMemberIds },
} = useMember();
const { description_html } = usePage(pageId.toString() ?? "");
// derived values
const projectMemberIds = projectId ? getProjectMemberIds(projectId.toString()) : [];
const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite);
// issue-embed
const { issueEmbedProps } = useIssueEmbed(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "");
// use-mention
const { mentionHighlights } = useMention({
workspaceSlug: workspaceSlug?.toString() ?? "",
projectId: projectId?.toString() ?? "",
members: projectMemberDetails,
user: currentUser ?? undefined,
});
// page filters
const { fontSize, fontStyle } = usePageFilters();
const displayConfig: TDisplayConfig = {
fontSize,
fontStyle,
};
if (!isCurrentVersionActive && !versionDetails)
return (
<div className="size-full px-5">
<Loader className="relative space-y-4">
<Loader.Item width="50%" height="36px" />
<div className="space-y-2">
<div className="py-2">
<Loader.Item width="100%" height="36px" />
</div>
<Loader.Item width="80%" height="22px" />
<div className="relative flex items-center gap-2">
<Loader.Item width="30px" height="30px" />
<Loader.Item width="30%" height="22px" />
</div>
<div className="py-2">
<Loader.Item width="60%" height="36px" />
</div>
<Loader.Item width="70%" height="22px" />
<Loader.Item width="30%" height="22px" />
<div className="relative flex items-center gap-2">
<Loader.Item width="30px" height="30px" />
<Loader.Item width="30%" height="22px" />
</div>
<div className="py-2">
<Loader.Item width="50%" height="30px" />
</div>
<Loader.Item width="100%" height="22px" />
<div className="py-2">
<Loader.Item width="30%" height="30px" />
</div>
<Loader.Item width="30%" height="22px" />
<div className="relative flex items-center gap-2">
<div className="py-2">
<Loader.Item width="30px" height="30px" />
</div>
<Loader.Item width="30%" height="22px" />
</div>
</div>
</Loader>
</div>
);
return (
<DocumentReadOnlyEditorWithRef
id={activeVersion ?? ""}
initialValue={(isCurrentVersionActive ? description_html : versionDetails?.description_html) ?? "<p></p>"}
containerClassName="p-0 pb-64 border-none"
displayConfig={displayConfig}
editorClassName="pl-10"
mentionHandler={{
highlights: mentionHighlights,
}}
embedHandler={{
issue: {
widgetCallback: issueEmbedProps.widgetCallback,
},
}}
/>
);
});
@@ -0,0 +1,6 @@
export * from "./editor";
export * from "./main-content";
export * from "./root";
export * from "./sidebar-list-item";
export * from "./sidebar-list";
export * from "./sidebar-root";
@@ -0,0 +1,114 @@
import { useState } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { TriangleAlert } from "lucide-react";
// plane types
import { TPageVersion } from "@plane/types";
// plane ui
import { Button, setToast, TOAST_TYPE } from "@plane/ui";
// components
import { PagesVersionEditor } from "@/components/pages";
// helpers
import { renderFormattedDate, renderFormattedTime } from "@/helpers/date-time.helper";
type Props = {
activeVersion: string | null;
fetchVersionDetails: (pageId: string, versionId: string) => Promise<TPageVersion | undefined>;
handleClose: () => void;
handleRestore: (descriptionHTML: string) => Promise<void>;
pageId: string;
};
export const PageVersionsMainContent: React.FC<Props> = observer((props) => {
const { activeVersion, fetchVersionDetails, handleClose, handleRestore, pageId } = props;
// states
const [isRestoring, setIsRestoring] = useState(false);
const [isRetrying, setIsRetrying] = useState(false);
const {
data: versionDetails,
error: versionDetailsError,
mutate: mutateVersionDetails,
} = useSWR(
pageId && activeVersion && activeVersion !== "current" ? `PAGE_VERSION_${activeVersion}` : null,
pageId && activeVersion && activeVersion !== "current" ? () => fetchVersionDetails(pageId, activeVersion) : null
);
const isCurrentVersionActive = activeVersion === "current";
const handleRestoreVersion = async () => {
setIsRestoring(true);
await handleRestore(versionDetails?.description_html ?? "<p></p>")
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Page version restored.",
});
handleClose();
})
.catch(() =>
setToast({
type: TOAST_TYPE.ERROR,
title: "Failed to restore page version.",
})
)
.finally(() => setIsRestoring(false));
};
const handleRetry = async () => {
setIsRetrying(true);
await mutateVersionDetails();
setIsRetrying(false);
};
return (
<div className="flex-grow flex flex-col">
{versionDetailsError ? (
<div className="flex-grow grid place-items-center">
<div className="flex flex-col items-center gap-4 text-center">
<span className="flex-shrink-0 grid place-items-center size-11 text-custom-text-300">
<TriangleAlert className="size-10" />
</span>
<div>
<h6 className="text-lg font-semibold">Something went wrong!</h6>
<p className="text-sm text-custom-text-300">The version could not be loaded, please try again.</p>
</div>
<Button variant="link-primary" onClick={handleRetry} loading={isRetrying}>
Try again
</Button>
</div>
</div>
) : (
<>
<div className="min-h-14 py-3 px-5 border-b border-custom-border-200 flex items-center justify-between gap-2">
<h6 className="text-base font-medium">
{isCurrentVersionActive
? "Current version"
: versionDetails
? `${renderFormattedDate(versionDetails.last_saved_at)} ${renderFormattedTime(versionDetails.last_saved_at)}`
: "Loading version details"}
</h6>
{!isCurrentVersionActive && (
<Button
variant="primary"
size="sm"
className="flex-shrink-0"
onClick={handleRestoreVersion}
loading={isRestoring}
>
{isRestoring ? "Restoring" : "Restore"}
</Button>
)}
</div>
<div className="pt-8 h-full overflow-y-scroll vertical-scrollbar scrollbar-sm">
<PagesVersionEditor
activeVersion={activeVersion}
isCurrentVersionActive={isCurrentVersionActive}
versionDetails={versionDetails}
/>
</div>
</>
)}
</div>
);
});
@@ -0,0 +1,50 @@
// plane types
import { TPageVersion } from "@plane/types";
// components
import { PageVersionsMainContent, PageVersionsSidebarRoot } from "@/components/pages";
// helpers
import { cn } from "@/helpers/common.helper";
type Props = {
activeVersion: string | null;
fetchAllVersions: (pageId: string) => Promise<TPageVersion[] | undefined>;
fetchVersionDetails: (pageId: string, versionId: string) => Promise<TPageVersion | undefined>;
handleRestore: (descriptionHTML: string) => Promise<void>;
isOpen: boolean;
onClose: () => void;
pageId: string;
};
export const PageVersionsOverlay: React.FC<Props> = (props) => {
const { activeVersion, fetchAllVersions, fetchVersionDetails, handleRestore, isOpen, onClose, pageId } = props;
const handleClose = () => {
onClose();
};
return (
<div
className={cn(
"absolute inset-0 z-10 size-full bg-custom-background-100 flex overflow-hidden opacity-0 pointer-events-none transition-opacity",
{
"opacity-100 pointer-events-auto": isOpen,
}
)}
>
<PageVersionsMainContent
activeVersion={activeVersion}
fetchVersionDetails={fetchVersionDetails}
handleClose={handleClose}
handleRestore={handleRestore}
pageId={pageId}
/>
<PageVersionsSidebarRoot
activeVersion={activeVersion}
fetchAllVersions={fetchAllVersions}
handleClose={handleClose}
isOpen={isOpen}
pageId={pageId}
/>
</div>
);
};
@@ -0,0 +1,48 @@
import { observer } from "mobx-react";
import Link from "next/link";
// plane types
import { TPageVersion } from "@plane/types";
// plane ui
import { Avatar } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common.helper";
import { renderFormattedDate, renderFormattedTime } from "@/helpers/date-time.helper";
// hooks
import { useMember } from "@/hooks/store";
type Props = {
href: string;
isActive: boolean;
version: TPageVersion;
};
export const PlaneVersionsSidebarListItem: React.FC<Props> = observer((props) => {
const { href, isActive, version } = props;
// store hooks
const { getUserDetails } = useMember();
// derived values
const ownerDetails = getUserDetails(version.owned_by);
return (
<Link
href={href}
className={cn("block p-2 rounded-md w-72 hover:bg-custom-background-80 transition-colors", {
"bg-custom-background-80": isActive,
})}
>
<p className="text-sm font-medium truncate">
{renderFormattedDate(version.last_saved_at)} {renderFormattedTime(version.last_saved_at)}
</p>
<p className="mt-2 flex items-center gap-1 text-xs">
<Avatar
src={ownerDetails?.avatar}
name={ownerDetails?.display_name}
shape="square"
size="sm"
className="flex-shrink-0"
/>
<span className="text-custom-text-300">{ownerDetails?.display_name}</span>
</p>
</Link>
);
});
@@ -0,0 +1,99 @@
import { useState } from "react";
import Link from "next/link";
import useSWR from "swr";
import { TriangleAlert } from "lucide-react";
// plane types
import { TPageVersion } from "@plane/types";
// plane ui
import { Button, Loader } from "@plane/ui";
// components
import { PlaneVersionsSidebarListItem } from "@/components/pages";
// helpers
import { cn } from "@/helpers/common.helper";
// hooks
import { useQueryParams } from "@/hooks/use-query-params";
type Props = {
activeVersion: string | null;
fetchAllVersions: (pageId: string) => Promise<TPageVersion[] | undefined>;
isOpen: boolean;
pageId: string;
};
export const PageVersionsSidebarList: React.FC<Props> = (props) => {
const { activeVersion, fetchAllVersions, isOpen, pageId } = props;
// states
const [isRetrying, setIsRetrying] = useState(false);
// update query params
const { updateQueryParams } = useQueryParams();
const {
data: versionsList,
error: versionsListError,
mutate: mutateVersionsList,
} = useSWR(
pageId && isOpen ? `PAGE_VERSIONS_LIST_${pageId}` : null,
pageId && isOpen ? () => fetchAllVersions(pageId) : null
);
const handleRetry = async () => {
setIsRetrying(true);
await mutateVersionsList();
setIsRetrying(false);
};
const getVersionLink = (versionID: string) =>
updateQueryParams({
paramsToAdd: { version: versionID },
});
return (
<div className="mt-4 px-4 h-full flex flex-col space-y-2 overflow-y-scroll vertical-scrollbar scrollbar-sm">
<Link
href={getVersionLink("current")}
className={cn("block p-2 rounded-md w-72 hover:bg-custom-background-80 transition-colors", {
"bg-custom-background-80": activeVersion === "current",
})}
>
<p className="text-sm font-medium">Current version</p>
</Link>
{versionsListError ? (
<div className="h-full grid place-items-center">
<div className="flex flex-col items-center gap-4 text-center">
<span className="flex-shrink-0 grid place-items-center size-11 text-custom-text-300">
<TriangleAlert className="size-10" />
</span>
<div>
<h6 className="text-base font-semibold">Something went wrong!</h6>
<p className="text-xs text-custom-text-300">
There was a problem while loading previous
<br />
versions, please try again.
</p>
</div>
<Button variant="link-primary" onClick={handleRetry} loading={isRetrying}>
Try again
</Button>
</div>
</div>
) : versionsList ? (
versionsList.map((version) => (
<PlaneVersionsSidebarListItem
key={version.id}
href={getVersionLink(version.id)}
isActive={activeVersion === version.id}
version={version}
/>
))
) : (
<Loader className="space-y-4">
<Loader.Item height="56px" />
<Loader.Item height="56px" />
<Loader.Item height="56px" />
<Loader.Item height="56px" />
<Loader.Item height="56px" />
</Loader>
)}
</div>
);
};
@@ -0,0 +1,38 @@
import { X } from "lucide-react";
// plane types
import { TPageVersion } from "@plane/types";
// components
import { PageVersionsSidebarList } from "@/components/pages";
type Props = {
activeVersion: string | null;
fetchAllVersions: (pageId: string) => Promise<TPageVersion[] | undefined>;
handleClose: () => void;
isOpen: boolean;
pageId: string;
};
export const PageVersionsSidebarRoot: React.FC<Props> = (props) => {
const { activeVersion, fetchAllVersions, handleClose, isOpen, pageId } = props;
return (
<div className="flex-shrink-0 py-4 border-l border-custom-border-200 flex flex-col">
<div className="px-6 flex items-center justify-between gap-2">
<h5 className="text-base font-semibold">Version history</h5>
<button
type="button"
onClick={handleClose}
className="flex-shrink-0 size-6 grid place-items-center text-custom-text-300 hover:text-custom-text-100 transition-colors"
>
<X className="size-4" />
</button>
</div>
<PageVersionsSidebarList
activeVersion={activeVersion}
fetchAllVersions={fetchAllVersions}
isOpen={isOpen}
pageId={pageId}
/>
</div>
);
};
@@ -6,6 +6,7 @@ import useSWR from "swr";
// ui
import { Loader } from "@plane/ui";
// components
import { BoxContainer } from "@/components/containers";
import { ActivityMessage, IssueLink } from "@/components/core";
import { ProfileEmptyState } from "@/components/ui";
// constants
@@ -39,7 +40,7 @@ export const ProfileActivity = observer(() => {
return (
<div className="space-y-2">
<h3 className="text-lg font-medium">Recent activity</h3>
<div className="rounded border border-custom-border-100 p-6">
<BoxContainer>
{userProfileActivity ? (
userProfileActivity.results.length > 0 ? (
<div className="space-y-5">
@@ -94,7 +95,7 @@ export const ProfileActivity = observer(() => {
<Loader.Item height="40px" />
</Loader>
)}
</div>
</BoxContainer>
</div>
);
});
@@ -3,6 +3,7 @@
// ui
import { IUserProfileData } from "@plane/types";
import { Loader } from "@plane/ui";
import { BoxContainer } from "@/components/containers";
import { BarGraph, ProfileEmptyState } from "@/components/ui";
// image
import { capitalizeFirstLetter } from "@/helpers/string.helper";
@@ -18,7 +19,7 @@ export const ProfilePriorityDistribution: React.FC<Props> = ({ userProfile }) =>
<div className="flex flex-col space-y-2">
<h3 className="text-lg font-medium">Issues by Priority</h3>
{userProfile ? (
<div className="flex-grow rounded border border-custom-border-100">
<BoxContainer className="flex-grow">
{userProfile.priority_distribution.length > 0 ? (
<BarGraph
data={userProfile.priority_distribution.map((priority) => ({
@@ -74,7 +75,7 @@ export const ProfilePriorityDistribution: React.FC<Props> = ({ userProfile }) =>
/>
</div>
)}
</div>
</BoxContainer>
) : (
<div className="grid place-items-center p-7">
<Loader className="flex items-end gap-12">
@@ -1,5 +1,6 @@
// ui
import { IUserProfileData, IUserStateDistribution } from "@plane/types";
import { BoxContainer } from "@/components/containers";
import { ProfileEmptyState, PieGraph } from "@/components/ui";
// image
import { STATE_GROUPS } from "@/constants/state";
@@ -18,7 +19,7 @@ export const ProfileStateDistribution: React.FC<Props> = ({ stateDistribution, u
return (
<div className="flex flex-col space-y-2">
<h3 className="text-lg font-medium">Issues by state</h3>
<div className="flex-grow rounded border border-custom-border-100 p-7">
<BoxContainer className="flex-grow">
{userProfile.state_distribution.length > 0 ? (
<div className="grid grid-cols-1 gap-x-6 md:grid-cols-2">
<div>
@@ -80,7 +81,7 @@ export const ProfileStateDistribution: React.FC<Props> = ({ stateDistribution, u
image={stateGraph}
/>
)}
</div>
</BoxContainer>
</div>
);
};
@@ -7,6 +7,7 @@ import { useParams } from "next/navigation";
import { UserCircle2 } from "lucide-react";
import { IUserProfileData } from "@plane/types";
import { CreateIcon, LayerStackIcon, Loader } from "@plane/ui";
import { BoxContainer } from "@/components/containers";
// types
type Props = {
@@ -44,7 +45,7 @@ export const ProfileStats: React.FC<Props> = ({ userProfile }) => {
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{overviewCards.map((card) => (
<Link key={card.route} href={`/${workspaceSlug}/profile/${userId}/${card.route}`}>
<span className="flex items-center gap-3 whitespace-nowrap rounded border border-custom-border-100 p-4">
<BoxContainer className="flex gap-2 p-4">
<div className="grid h-11 w-11 place-items-center rounded bg-custom-background-90">
<card.icon className="h-5 w-5" />
</div>
@@ -52,7 +53,7 @@ export const ProfileStats: React.FC<Props> = ({ userProfile }) => {
<p className="text-sm text-custom-text-400">{card.title}</p>
<p className="text-xl font-semibold">{card.value}</p>
</div>
</span>
</BoxContainer>
</Link>
))}
</div>
@@ -1,5 +1,6 @@
// types
import { IUserStateDistribution } from "@plane/types";
import { BoxContainer } from "@/components/containers";
import { STATE_GROUPS } from "@/constants/state";
// constants
@@ -12,26 +13,24 @@ export const ProfileWorkload: React.FC<Props> = ({ stateDistribution }) => (
<h3 className="text-lg font-medium">Workload</h3>
<div className="grid grid-cols-1 justify-stretch gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{stateDistribution.map((group) => (
<div key={group.state_group}>
<a className="flex gap-2 whitespace-nowrap rounded border border-custom-border-100 p-4">
<div
className="h-3 w-3 rounded-sm"
style={{
backgroundColor: STATE_GROUPS[group.state_group].color,
}}
/>
<div className="-mt-1 space-y-1">
<p className="text-sm text-custom-text-400">
{group.state_group === "unstarted"
? "Not started"
: group.state_group === "started"
? "Working on"
: STATE_GROUPS[group.state_group].label}
</p>
<p className="text-xl font-semibold">{group.state_count}</p>
</div>
</a>
</div>
<BoxContainer key={group.state_group} className="flex gap-2 p-4">
<div
className="h-3 w-3 rounded-sm"
style={{
backgroundColor: STATE_GROUPS[group.state_group].color,
}}
/>
<div className="-mt-1 space-y-1">
<p className="text-sm text-custom-text-400">
{group.state_group === "unstarted"
? "Not started"
: group.state_group === "started"
? "Working on"
: STATE_GROUPS[group.state_group].label}
</p>
<p className="text-xl font-semibold">{group.state_count}</p>
</div>
</BoxContainer>
))}
</div>
</div>
+93
View File
@@ -0,0 +1,93 @@
import { useCallback } from "react";
import { useParams } from "next/navigation";
import { ListFilter } from "lucide-react";
// types
import { cn } from "@plane/editor";
import { TProjectFilters } from "@plane/types";
// components
import { FiltersDropdown } from "@/components/issues";
import { ProjectFiltersSelection, ProjectOrderByDropdown } from "@/components/project";
// helpers
import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks
import { useMember, useProjectFilter } from "@/hooks/store";
type Props = {
filterMenuButton?: React.ReactNode;
classname?: string;
filterClassname?: string;
isMobile?: boolean;
};
const HeaderFilters = ({ filterMenuButton, isMobile, classname = "", filterClassname = "" }: Props) => {
// router
const { workspaceSlug } = useParams();
const {
currentWorkspaceDisplayFilters: displayFilters,
currentWorkspaceFilters: filters,
updateFilters,
updateDisplayFilters,
} = useProjectFilter();
const {
workspace: { workspaceMemberIds },
} = useMember();
const handleFilters = useCallback(
(key: keyof TProjectFilters, value: string | string[]) => {
if (!workspaceSlug) return;
let newValues = filters?.[key] ?? [];
if (Array.isArray(value)) {
if (key === "created_at" && newValues.find((v) => v.includes("custom"))) newValues = [];
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
} else {
if (filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else {
if (key === "created_at") newValues = [value];
else newValues.push(value);
}
}
updateFilters(workspaceSlug.toString(), { [key]: newValues });
},
[filters, updateFilters, workspaceSlug]
);
const isFiltersApplied = calculateTotalFilters(filters ?? {}) !== 0;
return (
<div className={cn("flex gap-3", classname)}>
<ProjectOrderByDropdown
value={displayFilters?.order_by}
onChange={(val) => {
if (!workspaceSlug || val === displayFilters?.order_by) return;
updateDisplayFilters(workspaceSlug.toString(), {
order_by: val,
});
}}
isMobile={isMobile}
/>
<div className={cn(filterClassname)}>
<FiltersDropdown
icon={<ListFilter className="h-3 w-3" />}
title="Filters"
placement="bottom-end"
isFiltersApplied={isFiltersApplied}
menuButton={filterMenuButton || null}
>
<ProjectFiltersSelection
displayFilters={displayFilters ?? {}}
filters={filters ?? {}}
handleFiltersUpdate={handleFilters}
handleDisplayFiltersUpdate={(val) => {
if (!workspaceSlug) return;
updateDisplayFilters(workspaceSlug.toString(), val);
}}
memberIds={workspaceMemberIds ?? undefined}
/>
</FiltersDropdown>
</div>
</div>
);
};
export default HeaderFilters;
+27 -90
View File
@@ -1,29 +1,24 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams, usePathname } from "next/navigation";
import { Search, Briefcase, X, ListFilter } from "lucide-react";
// types
import { TProjectFilters } from "@plane/types";
import { usePathname } from "next/navigation";
import { Search, Briefcase, X } from "lucide-react";
// ui
import { Breadcrumbs, Button } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
import { FiltersDropdown } from "@/components/issues";
import { ProjectFiltersSelection, ProjectOrderByDropdown } from "@/components/project";
import { HeaderContainer } from "@/components/containers";
// constants
import { EUserWorkspaceRoles } from "@/constants/workspace";
// helpers
import { cn } from "@/helpers/common.helper";
import { calculateTotalFilters } from "@/helpers/filter.helper";
// hooks
import { useCommandPalette, useEventTracker, useMember, useProjectFilter, useUser } from "@/hooks/store";
import { useCommandPalette, useEventTracker, useProjectFilter, useUser } from "@/hooks/store";
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
import HeaderFilters from "./filters";
export const ProjectsBaseHeader = observer(() => {
// router
const { workspaceSlug } = useParams();
// states
const [isSearchOpen, setIsSearchOpen] = useState(false);
// refs
@@ -36,17 +31,8 @@ export const ProjectsBaseHeader = observer(() => {
} = useUser();
const pathname = usePathname();
const {
currentWorkspaceDisplayFilters: displayFilters,
currentWorkspaceFilters: filters,
updateFilters,
updateDisplayFilters,
searchQuery,
updateSearchQuery,
} = useProjectFilter();
const {
workspace: { workspaceMemberIds },
} = useMember();
const { searchQuery, updateSearchQuery } = useProjectFilter();
// outside click detector hook
useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
@@ -55,29 +41,6 @@ export const ProjectsBaseHeader = observer(() => {
const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
const isArchived = pathname.includes("/archives");
const handleFilters = useCallback(
(key: keyof TProjectFilters, value: string | string[]) => {
if (!workspaceSlug) return;
let newValues = filters?.[key] ?? [];
if (Array.isArray(value)) {
if (key === "created_at" && newValues.find((v) => v.includes("custom"))) newValues = [];
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
} else {
if (filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else {
if (key === "created_at") newValues = [value];
else newValues.push(value);
}
}
updateFilters(workspaceSlug.toString(), { [key]: newValues });
},
[filters, updateFilters, workspaceSlug]
);
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
if (searchQuery && searchQuery.trim() !== "") updateSearchQuery("");
@@ -89,22 +52,18 @@ export const ProjectsBaseHeader = observer(() => {
if (searchQuery.trim() !== "") setIsSearchOpen(true);
}, [searchQuery]);
const isFiltersApplied = calculateTotalFilters(filters ?? {}) !== 0;
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 flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<div>
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
type="text"
link={<BreadcrumbLink label="Projects" icon={<Briefcase className="h-4 w-4 text-custom-text-300" />} />}
/>
{isArchived && <Breadcrumbs.BreadcrumbItem type="text" link={<BreadcrumbLink label="Archived" />} />}
</Breadcrumbs>
</div>
</div>
<div className="w-full flex items-center justify-end gap-3">
<HeaderContainer>
<HeaderContainer.LeftItem>
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
type="text"
link={<BreadcrumbLink label="Projects" icon={<Briefcase className="h-4 w-4 text-custom-text-300" />} />}
/>
{isArchived && <Breadcrumbs.BreadcrumbItem type="text" link={<BreadcrumbLink label="Archived" />} />}
</Breadcrumbs>
</HeaderContainer.LeftItem>
<HeaderContainer.RightItem>
<div className="flex items-center">
{!isSearchOpen && (
<button
@@ -149,35 +108,11 @@ export const ProjectsBaseHeader = observer(() => {
)}
</div>
</div>
<div className="hidden md:flex gap-3">
<ProjectOrderByDropdown
value={displayFilters?.order_by}
onChange={(val) => {
if (!workspaceSlug || val === displayFilters?.order_by) return;
updateDisplayFilters(workspaceSlug.toString(), {
order_by: val,
});
}}
/>
<FiltersDropdown
icon={<ListFilter className="h-3 w-3" />}
title="Filters"
placement="bottom-end"
isFiltersApplied={isFiltersApplied}
>
<ProjectFiltersSelection
displayFilters={displayFilters ?? {}}
filters={filters ?? {}}
handleFiltersUpdate={handleFilters}
handleDisplayFiltersUpdate={(val) => {
if (!workspaceSlug) return;
updateDisplayFilters(workspaceSlug.toString(), val);
}}
memberIds={workspaceMemberIds ?? undefined}
/>
</FiltersDropdown>
<div className="hidden md:flex">
<HeaderFilters />
</div>
{isAuthorizedUser && (
{isAuthorizedUser ? (
<Button
size="sm"
onClick={() => {
@@ -188,8 +123,10 @@ export const ProjectsBaseHeader = observer(() => {
>
<span className="hidden sm:inline-block">Add</span> Project
</Button>
) : (
<></>
)}
</div>
</div>
</HeaderContainer.RightItem>
</HeaderContainer>
);
});
@@ -6,6 +6,7 @@ import { Inbox } from "lucide-react";
import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common";
import { HeaderContainer } from "@/components/containers";
import { SidebarHamburgerToggle } from "@/components/core";
import { NotificationSidebarHeaderOptions } from "@/components/workspace-notifications";
@@ -18,8 +19,8 @@ export const NotificationSidebarHeader: FC<TNotificationSidebarHeader> = observe
if (!workspaceSlug) return <></>;
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">
<HeaderContainer>
<HeaderContainer.LeftItem>
<div className="block bg-custom-sidebar-background-100 md:hidden">
<SidebarHamburgerToggle />
</div>
@@ -31,9 +32,10 @@ export const NotificationSidebarHeader: FC<TNotificationSidebarHeader> = observe
}
/>
</Breadcrumbs>
</div>
<NotificationSidebarHeaderOptions workspaceSlug={workspaceSlug} />
</div>
</HeaderContainer.LeftItem>
<HeaderContainer.RightItem>
<NotificationSidebarHeaderOptions workspaceSlug={workspaceSlug} />
</HeaderContainer.RightItem>
</HeaderContainer>
);
});
@@ -5,6 +5,7 @@ import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// components
import { CountChip } from "@/components/common";
import { RegularRow } from "@/components/containers";
import {
NotificationsLoader,
NotificationEmptyState,
@@ -46,11 +47,11 @@ export const NotificationsSidebar: FC = observer(() => {
)}
>
<div className="relative w-full h-full overflow-hidden flex flex-col">
<div className="border-b border-custom-border-200">
<RegularRow className="h-[3.75rem] border-b border-custom-border-200">
<NotificationSidebarHeader workspaceSlug={workspaceSlug.toString()} />
</div>
</RegularRow>
<div className="flex-shrink-0 w-full h-[46px] border-b border-custom-border-200 px-5 relative flex items-center gap-2">
<div className="flex-shrink-0 w-full h-[46px] border-b border-custom-border-200 px-page-x relative flex items-center gap-2">
{NOTIFICATION_TABS.map((tab) => (
<div
key={tab.value}
@@ -108,7 +108,7 @@ export const GlobalViewsHeader: React.FC = observer(() => {
<div className="group relative flex border-b border-custom-border-200">
<div
ref={containerRef}
className="flex w-full items-center overflow-x-auto px-4 horizontal-scrollbar scrollbar-sm"
className="flex w-full items-center overflow-x-auto px-page-x horizontal-scrollbar scrollbar-sm"
>
{DEFAULT_GLOBAL_VIEWS_LIST.map((tab, index) => (
<DefaultViewTab key={`${tab.key}-${index}`} tab={tab} />
+11
View File
@@ -3,6 +3,8 @@
import { useCallback, useEffect, useMemo } from "react";
// hooks
import { useMultipleSelectStore } from "@/hooks/store";
//
import useReloadConfirmations from "./use-reload-confirmation";
export type TEntityDetails = {
entityID: string;
@@ -52,6 +54,15 @@ export const useMultipleSelect = (props: Props) => {
getEntityDetailsFromEntityID,
} = useMultipleSelectStore();
useReloadConfirmations(
selectedEntityIds && selectedEntityIds.length > 0,
"Are you sure you want to leave? Your current bulk operation selections will be lost.",
true,
() => {
clearSelection();
}
);
const groups = useMemo(() => Object.keys(entities), [entities]);
const entitiesList: TEntityDetails[] = useMemo(
+16 -3
View File
@@ -1,20 +1,19 @@
import React, { useCallback, useEffect, useState } from "react";
import useSWR from "swr";
// plane editor
import {
EditorRefApi,
proseMirrorJSONToBinaryString,
applyUpdates,
generateJSONfromHTMLForDocumentEditor,
} from "@plane/editor";
// hooks
import { setToast, TOAST_TYPE } from "@plane/ui";
import useAutoSave from "@/hooks/use-auto-save";
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
// services
import { ProjectPageService } from "@/services/page";
// store
import { IPage } from "@/store/pages/page";
const projectPageService = new ProjectPageService();
@@ -183,6 +182,19 @@ export const usePageDescription = (props: Props) => {
]
);
const manuallyUpdateDescription = async (descriptionHTML: string) => {
const { contentJSON, editorSchema } = generateJSONfromHTMLForDocumentEditor(descriptionHTML ?? "<p></p>");
const yDocBinaryString = proseMirrorJSONToBinaryString(contentJSON, "default", editorSchema);
try {
editorRef.current?.clearEditor(true);
await updateDescription(yDocBinaryString, descriptionHTML ?? "<p></p>");
await mutateDescriptionYJS();
} catch (error) {
console.log("error", error);
}
};
useAutoSave(handleSaveDescription);
return {
@@ -190,5 +202,6 @@ export const usePageDescription = (props: Props) => {
isDescriptionReady,
pageDescriptionYJS,
handleSaveDescription,
manuallyUpdateDescription,
};
};
+39
View File
@@ -0,0 +1,39 @@
import { useSearchParams, usePathname } from "next/navigation";
type TParamsToAdd = {
[key: string]: string;
};
export const useQueryParams = () => {
// next navigation
const searchParams = useSearchParams();
const pathname = usePathname();
const updateQueryParams = ({
paramsToAdd = {},
paramsToRemove = [],
}: {
paramsToAdd?: TParamsToAdd;
paramsToRemove?: string[];
}) => {
const currentParams = new URLSearchParams(searchParams.toString());
// add or update query parameters
Object.keys(paramsToAdd).forEach((key) => {
currentParams.set(key, paramsToAdd[key]);
});
// remove specified query parameters
paramsToRemove.forEach((key) => {
currentParams.delete(key);
});
// construct the new route with the updated query parameters
const newRoute = `${pathname}?${currentParams.toString()}`;
return newRoute;
};
return {
updateQueryParams,
};
};
+8 -4
View File
@@ -1,8 +1,10 @@
import { useCallback, useEffect, useState } from "react";
//TODO: remove temp flag isActive later and use showAlert as the source of truth
const useReloadConfirmations = (isActive = true) => {
const [showAlert, setShowAlert] = useState(false);
const useReloadConfirmations = (isActive = true, message?: string, defaultShowAlert = false, onLeave?: () => void) => {
const [showAlert, setShowAlert] = useState(defaultShowAlert);
const alertMessage = message ?? "Are you sure you want to leave? Changes you made may not be saved.";
const handleBeforeUnload = useCallback(
(event: BeforeUnloadEvent) => {
@@ -28,8 +30,10 @@ const useReloadConfirmations = (isActive = true) => {
const isAnchorTargetBlank = anchorElement.getAttribute("target") === "_blank";
if (isAnchorTargetBlank) return;
// show confirm dialog
const leave = confirm("Are you sure you want to leave? Changes you made may not be saved.");
if (!leave) {
const isLeaving = confirm(alertMessage);
if (isLeaving) {
onLeave && onLeave();
} else {
event.preventDefault();
event.stopPropagation();
}
+1
View File
@@ -1 +1,2 @@
export * from "./project-page-version.service";
export * from "./project-page.service";
@@ -0,0 +1,33 @@
// plane types
import { TPageVersion } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
export class ProjectPageVersionService extends APIService {
constructor() {
super(API_BASE_URL);
}
async fetchAllVersions(workspaceSlug: string, projectId: string, pageId: string): Promise<TPageVersion[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/versions/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async fetchVersionById(
workspaceSlug: string,
projectId: string,
pageId: string,
versionId: string
): Promise<TPageVersion> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/versions/${versionId}/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
}