feat: app dir migration (#4743)
* feat: creating new app dir structure for web app * fix: moving few pages to app dir * fix: adding profile settings layout * fix: errors on app dir. * chore: remove pages routes. * chore: add sign-in/ sign-up, invitations, onboarding pages. * [WEB-1374] fix: clear changes made on modal close (#4555) * [WEB-1480] fix: preserve page access when making a copy (#4568) * [WEB-1465] fix: theme fluctuation on initial load. (#4638) * [WEB-1445] fix: issue creation on sub groups when cycle/ module grouping is applied. (#4636) * [WEB-1244] fix: add better image insertion and replacement logic in the editor (#4508) * fix: add better image insertion and replacement logic * refactor: image handling in editor * chore: remove passing uploadKey around * refactor: remove unused code * fix: redundant files removed * fix: add is editor ready to discard api to control behvaiours from our app * fix: focus issues and image insertion position when not using slash command * fix: import order fixed * fix: notification mark all as read (#4643) * chore: remove enter key extension (#4648) * [WEB-1467] chore: run the API's required to bootstrap the application in parallel. (#4642) * [WEB - 1482] fix: uploads when using block storages other than s3 and minio (#4647) * fix: minio storage and redirection * dev: disconnect web url and app base url configuration. * fix: negate check while trying to discard (#4653) * fix: email notification preferences (#4656) * [WEB-1493] chore: product tour asset and app sidebar quick action hover (#4655) * chore: product tour asset updated * fix: app sidebar quick action hover * fix: project state setting state name remove camel case logic (#4652) * [WEB-1419] chore: enable module creation with dates older than today. (#4659) * [WEB-1216] chore: increase module empty state for consistency. (#4658) * fix: build errors * [WEB-1235] chore: module and cycle sidebar graph improvement (#4650) * chore: module and cycle sidebar graph improvement * chore: code refactor * [WEB-1424] chore: page and view logo implementation, and emoji/icon (#4662) * [WEB-1424] chore: page and view logo implementation, and emoji/icon picker improvement (#4583) * chore: added logo_props * chore: logo props in cycles, views and modules * chore: emoji icon picker types updated * chore: info icon added to plane ui package * chore: icon color adjust helper function added * style: icon picker ui improvement and default color options updated * chore: update page logo action added in store * chore: emoji code to unicode helper function added * chore: common logo renderer component added * chore: app header project logo updated * chore: project logo updated across platform * chore: page logo picker added * chore: control link component improvement * chore: list item improvement * chore: emoji picker component updated * chore: space app and package logo prop type updated * chore: migration * chore: logo added to project view * chore: page logo picker added in create modal and breadcrumbs * chore: view logo picker added in create modal and updated breadcrumbs * fix: build error * chore: AIO docker images for preview deployments (#4605) * fix: adding single docker base file * action added * fix action * dockerfile.base modified * action fix * dockerfile * fix: base aio dockerfile * fix: dockerfile.base * fix: dockerfile base * fix: modified folder structure * fix: action * fix: dockerfile * fix: dockerfile.base * fix: supervisor file name changed * fix: base dockerfile updated * fix dockerfile base * fix: base dockerfile * fix: docker files * fix: base dockerfile * update base image * modified docker aio base * aio base modified to debian-12-slim * fixes * finalize the dockerfiles with volume exposure * modified the aio build and dockerfile * fix: codacy suggestions implemented * fix: codacy fix * update aio build action --------- Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com> * fix: merge conflict * chore: lucide react added to planu ui package * chore: new emoji picker component added with lucid icon and code refactor * chore: logo component updated * chore: emoji picker updated for pages and views --------- Co-authored-by: NarayanBavisetti <narayan3119@gmail.com> Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com> Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com> * fix: build error --------- Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Co-authored-by: NarayanBavisetti <narayan3119@gmail.com> Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com> Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so> * refactor: drag handle component (#4663) * refactor: checkbox ui component (#4665) * [WEB-1325] chore: refactor inbox issue store to avoid data loss. (#4640) * [WEB-1325] chore: refactor inbox issue store to avoid data loss. * chore: inbox store improvement. * chore: priority dropdown accepts undefined (#4666) * chore: added buttonClassName prop to label dropdown (#4671) * chore: created new constants for marketing website page links (#4670) * chore: added a prop to render default state conditionally (#4669) * [WEB-1501] dev: multiple select core components (#4667) * dev: multiple select core components * chore: added export statement * chore: created a new constant for archivable state groups (#4668) * chore: added primary variant to the alert modal (#4664) * [WEB-1436] chore: pages improvement. (#4657) * add empty state if no pages are available. * set access to private in create page modal when the modal is open form private tab. * [WEB-1440] chore: update cycle empty state to use project level access. (#4672) * fix: checkbox ui component (#4675) * fix: ai buttons overlapping issue (#4621) * [WEB - 1500] chore: add extra fields on instance and create changelog table to store release change logs (#4673) * chore: add extra fields on instance and create changelog table to store release change logs * dev: rename new_version to latest_version * [WEB - 1505] chore: alter instance id field (#4676) * chore: instance id * dev: update to max length * feat: creating new app dir structure for web app * fix: moving few pages to app dir * feat: creating new app dir structure for web app * fix: moving few pages to app dir * fix: errors on app dir. * chore: remove pages routes. * chore: add sign-in/ sign-up, invitations, onboarding pages. * fix: instance serializer * fix: instance register script (#4681) * fix: instance register script * dev: remove api key and add latest version and current version in types * [WEB-1492] fix: resolved issue creation error in layouts while group_by and sub_group_by filters applied in quick add (#4682) * fix: resolved issue creation error in layouts while group_by and sub_group_by filters applied in quick add * fix: updated braces in conditions * fix: inbox issue store update logic. (#4683) * chore: update package version * [WEB-1184] feat: issue bulk operations (#4674) * feat: issue bulk operations * style: bulk operations action bar * chore: remove edition separation * style: fix overlapping of response container in AI popover. (#4684) * [WEB-1498] style: fix comments reaction alignment. (#4686) * [WEB-1503] chore: add `autofocus` to name field in inline create/ update state component. (#4685) * [WEB-1312] fix: trim file name before uploading (#4661) * fix: trim file name before uploading * fix: check the cursor position before inserting image * dev: add trimming for file assets * dev: add filename validation above if * dev: make the validation to 50 to support user uploads --------- Co-authored-by: pablohashescobar <nikhilschacko@gmail.com> * [WEB-1481] fix: multiple API calls in inbox issues on closed issues tab. (#4691) * fix: multiple API calls on scroll and closed issues tab. * fix: pagination loader on initial load. * feat: Add components required for estimates (#4690) * Add sortable, radio and typography components * Remove stray css classes * Prevent drag of items from other draggable * Minor cleanup * Update yarn.lock * Remove radio input component as it was build on headless ui v2.0.0 and now we are using v1.7.0 * Fix build errors * Update dependencies in use memo. * [WEB-1521] chore: add configuration to enable/disable sign-ups. (#4697) * fix: regenerating lock file * fix: docker image build errors * fix: remove `setupInterceptors` to avoid circular dependency. * chore: migrate all `accounts` related routes. * chore: migrate all `profiles` related routes. * chore: workspace invitation and onboarding migration / fixes. * chore: installation provider migrations. * regression: focus changing issue with the peek overview editor (#4700) * [WEB-1459] chore: save users all / favorite project list collapse state into localstorage. (#4701) * [WEB-1501] chore: update selected entity details on entities list change (#4702) * chore: update selected entity detials on entities list change * chore: addd selectionHelpers as a prop * [WEB-1517] chore: remove drag handle from list drag block (#4698) * remove drag handle from list drag block * align list group header with list item * rearrange chevron for list subissues and rearrange spaces * adding default draggable property to control link * remove unnecessary dependencies for useEffect * fix: email validation (#4707) * fix: email validation on complete login or sign up functionality * dev: add try catch block * dev: split up code * dev: empty return * fix: cache invalidation on new members invite (#4699) * fix: build test pull request running on non draft PRs (#4708) * fix: cache invalidation on new members invite (#4699) * fix: add version max length (#4713) * chore: migrations for `routing` hooks. * [WEB-1533] chore: fix alignment issues in List and Spreadsheet view (#4714) * fix alignment issues in List and Spreadsheet view * fix spreadsheet indentation * chore: migration for workspace dashboard/ views/ analytics/ settings and active-cycles. * chore: handle undefined identifier case * fix: Overflowing loader in issue edit modal (#4720) * [WEB-1529] chore: workspace sidebar updates. (#4710) * fix: temporary fix exiting lines with slashes (#4725) * [WEB-1537] fix: inline code block size fixed for headers, etc (#4709) * fix: inline code block size fixed for headers, etc * feat: persisting focus accurately post converting the code block into text * fix: typo in error handling * [WEB-1526] feat: add auto merge behaviour to task lists and fix infinite backspace case (#4703) * feat: add auto merge behaviour to task lists * fix: unhandled cases for taskItem and taskList * fix: css task list such that toggling task list doesn't shift things * fix: task list jumps around while trying create/delete things in between two task lists * fix: remove filtering for generic transactions i.e. transactions with some meta data while tying to join things * chore: migration for profile activity along with headers refactor. * [WEB-1201] dev: dropdowns (#4721) * chore: lodash package added * chore: dropdown key down hook added * dev: dropdown component * chore: build error and code refactor * chore: readme file updated * chore: added disabled prop to multiple select components (#4724) * chore: added disabled prop to mutliple select group hoc * style: fix empty space * fix: don't add as a sub-issue if parent has been removed (#4731) * fix: member list item custom menu placement (#4729) * [WEB-1535] chore: project logo picker improvement (#4718) * chore: emoji icon picker improvement * chore: emoji icon picker improvement * fix: resolved border flicker on issue title (#4727) * chore: profile activity empty state added (#4732) * [WEB-1481] fix: inbox issue list update after changing issue status. (#4715) * style: fix ux copy style on project feature preview page. (#4734) * chore: remove clear seleciton logic on escape key press (#4735) * chore: migrations for projects and project issues. * chore: issue and properties filter dropdown improvement (#4733) * save all filters and properties for views (#4728) * chore: migrations for issue details route. * chore: migration for cycle routes. * chore: migration for module routes. * chore: migrations for project views routes. * chore: migrations for project pages routes. * chore: migration for project inbox routes. * chore: migration for project settings routes. * chore: migrations for draft issues routes. * chore: migrations for project archives routes. * chore: remove unused headers. * temp: comment out auth constant and use-reload-confirmation code to avoid errors. --------- Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com> Co-authored-by: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Co-authored-by: M. Palanikannan <73993394+Palanikannan1437@users.noreply.github.com> Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Co-authored-by: Nikhil <118773738+pablohashescobar@users.noreply.github.com> Co-authored-by: NarayanBavisetti <narayan3119@gmail.com> Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com> Co-authored-by: Anmol Singh Bhatia <anmolsinghbhatia@plane.so> Co-authored-by: guru_sainath <gurusainath007@gmail.com> Co-authored-by: pablohashescobar <nikhilschacko@gmail.com> Co-authored-by: Satish Gandham <satish.iitg@gmail.com> Co-authored-by: Henit Chobisa <chobisa.henit@gmail.com> Co-authored-by: Aaryan Khandelwal <aaryankhandu123@gmail.com>
This commit is contained in:
parent
59fdd611e4
commit
5d807db69e
417 changed files with 5196 additions and 3918 deletions
31
web/app/[workspaceSlug]/@header/active-cycles/header.tsx
Normal file
31
web/app/[workspaceSlug]/@header/active-cycles/header.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { Crown } from "lucide-react";
|
||||
import { Breadcrumbs, ContrastIcon } from "@plane/ui";
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
// icons
|
||||
|
||||
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>
|
||||
<Crown className="h-3.5 w-3.5 text-amber-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
|
||||
export default WorkspaceActiveCycleHeader;
|
||||
9
web/app/[workspaceSlug]/@header/active-cycles/page.tsx
Normal file
9
web/app/[workspaceSlug]/@header/active-cycles/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import AppHeaderWrapper from "../../app-header-wrapper";
|
||||
import WorkspaceActiveCycleHeader from "./header";
|
||||
|
||||
const WorkspaceActiveCycleHeaderPage = () => <AppHeaderWrapper header={<WorkspaceActiveCycleHeader />} />;
|
||||
|
||||
export default WorkspaceActiveCycleHeaderPage;
|
||||
75
web/app/[workspaceSlug]/@header/analytics/header.tsx
Normal file
75
web/app/[workspaceSlug]/@header/analytics/header.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
// icons
|
||||
import { BarChart2, PanelRight } from "lucide-react";
|
||||
// ui
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useAppTheme } from "@/hooks/store";
|
||||
|
||||
const WorkspaceAnalyticsHeader = observer(() => {
|
||||
const searchParams = useSearchParams();
|
||||
const analytics_tab = searchParams.get("analytics_tab");
|
||||
// store hooks
|
||||
const { workspaceAnalyticsSidebarCollapsed, toggleWorkspaceAnalyticsSidebar } = useAppTheme();
|
||||
|
||||
useEffect(() => {
|
||||
const handleToggleWorkspaceAnalyticsSidebar = () => {
|
||||
if (window && window.innerWidth < 768) {
|
||||
toggleWorkspaceAnalyticsSidebar(true);
|
||||
}
|
||||
if (window && workspaceAnalyticsSidebarCollapsed && window.innerWidth >= 768) {
|
||||
toggleWorkspaceAnalyticsSidebar(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleToggleWorkspaceAnalyticsSidebar);
|
||||
handleToggleWorkspaceAnalyticsSidebar();
|
||||
return () => window.removeEventListener("resize", handleToggleWorkspaceAnalyticsSidebar);
|
||||
}, [toggleWorkspaceAnalyticsSidebar, workspaceAnalyticsSidebarCollapsed]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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" />} />
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default WorkspaceAnalyticsHeader;
|
||||
9
web/app/[workspaceSlug]/@header/analytics/page.tsx
Normal file
9
web/app/[workspaceSlug]/@header/analytics/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import AppHeaderWrapper from "../../app-header-wrapper";
|
||||
import WorkspaceAnalyticsHeader from "./header";
|
||||
|
||||
const WorkspaceAnalyticsHeaderPage = () => <AppHeaderWrapper header={<WorkspaceAnalyticsHeader />} />;
|
||||
|
||||
export default WorkspaceAnalyticsHeaderPage;
|
||||
76
web/app/[workspaceSlug]/@header/header.tsx
Normal file
76
web/app/[workspaceSlug]/@header/header.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Home, Zap } from "lucide-react";
|
||||
// images
|
||||
import githubBlackImage from "/public/logos/github-black.png";
|
||||
import githubWhiteImage from "/public/logos/github-white.png";
|
||||
// ui
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
// constants
|
||||
import { CHANGELOG_REDIRECTED, GITHUB_REDIRECTED } from "@/constants/event-tracker";
|
||||
// hooks
|
||||
import { useEventTracker } from "@/hooks/store";
|
||||
|
||||
const WorkspaceDashboardHeader = () => {
|
||||
// hooks
|
||||
const { captureEvent } = useEventTracker();
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
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">
|
||||
<div>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={<BreadcrumbLink label="Home" icon={<Home className="h-4 w-4 text-custom-text-300" />} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 px-3">
|
||||
<a
|
||||
onClick={() =>
|
||||
captureEvent(CHANGELOG_REDIRECTED, {
|
||||
element: "navbar",
|
||||
})
|
||||
}
|
||||
href="https://plane.so/changelog"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5"
|
||||
>
|
||||
<Zap size={14} strokeWidth={2} fill="rgb(var(--color-text-100))" />
|
||||
<span className="hidden text-xs font-medium sm:hidden md:block">{"What's new?"}</span>
|
||||
</a>
|
||||
<a
|
||||
onClick={() =>
|
||||
captureEvent(GITHUB_REDIRECTED, {
|
||||
element: "navbar",
|
||||
})
|
||||
}
|
||||
className="flex flex-shrink-0 items-center gap-1.5 rounded bg-custom-background-80 px-3 py-1.5"
|
||||
href="https://github.com/makeplane/plane"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
src={resolvedTheme === "dark" ? githubWhiteImage : githubBlackImage}
|
||||
height={16}
|
||||
width={16}
|
||||
alt="GitHub Logo"
|
||||
/>
|
||||
<span className="hidden text-xs font-medium sm:hidden md:block">Star us on GitHub</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkspaceDashboardHeader;
|
||||
9
web/app/[workspaceSlug]/@header/page.tsx
Normal file
9
web/app/[workspaceSlug]/@header/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import AppHeaderWrapper from "../app-header-wrapper";
|
||||
import WorkspaceDashboardHeader from "./header";
|
||||
|
||||
const WorkspaceDashboardHeaderPage = () => <AppHeaderWrapper header={<WorkspaceDashboardHeader />} />;
|
||||
|
||||
export default WorkspaceDashboardHeaderPage;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
|
||||
import UserProfileHeader from "../header";
|
||||
|
||||
const ProfileActivityHeader = () => <AppHeaderWrapper header={<UserProfileHeader type="Activity" />} />;
|
||||
|
||||
export default ProfileActivityHeader;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
|
||||
import UserProfileHeader from "../header";
|
||||
import ProfileIssuesMobileHeader from "../mobile-header";
|
||||
|
||||
const ProfileAssignedHeader = () => (
|
||||
<AppHeaderWrapper header={<UserProfileHeader type="Assigned" />} mobileHeader={<ProfileIssuesMobileHeader />} />
|
||||
);
|
||||
|
||||
export default ProfileAssignedHeader;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
|
||||
import UserProfileHeader from "../header";
|
||||
import ProfileIssuesMobileHeader from "../mobile-header";
|
||||
|
||||
const ProfileCreatedHeader = () => (
|
||||
<AppHeaderWrapper header={<UserProfileHeader type="Created" />} mobileHeader={<ProfileIssuesMobileHeader />} />
|
||||
);
|
||||
|
||||
export default ProfileCreatedHeader;
|
||||
94
web/app/[workspaceSlug]/@header/profile/[userId]/header.tsx
Normal file
94
web/app/[workspaceSlug]/@header/profile/[userId]/header.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"use client";
|
||||
|
||||
// ui
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ChevronDown, PanelRight } from "lucide-react";
|
||||
import { Breadcrumbs, CustomMenu } from "@plane/ui";
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
// components
|
||||
import { PROFILE_ADMINS_TAB, PROFILE_VIEWER_TAB } from "@/constants/profile";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { useAppTheme, useUser } from "@/hooks/store";
|
||||
|
||||
type TUserProfileHeader = {
|
||||
type?: string | undefined;
|
||||
};
|
||||
|
||||
const UserProfileHeader: FC<TUserProfileHeader> = observer((props) => {
|
||||
const { type = undefined } = props;
|
||||
// router
|
||||
const { workspaceSlug, userId } = useParams();
|
||||
// store hooks
|
||||
const { toggleProfileSidebar, profileSidebarCollapsed } = useAppTheme();
|
||||
const {
|
||||
membership: { currentWorkspaceRole },
|
||||
} = useUser();
|
||||
// derived values
|
||||
const AUTHORIZED_ROLES = [20, 15, 10];
|
||||
|
||||
if (!currentWorkspaceRole) return null;
|
||||
|
||||
const isAuthorized = AUTHORIZED_ROLES.includes(currentWorkspaceRole);
|
||||
const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB;
|
||||
|
||||
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 justify-between">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={<BreadcrumbLink href="/profile" label="Activity Overview" />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<div className="flex gap-4 md:hidden">
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className="flex flex-grow justify-center text-sm text-custom-text-200"
|
||||
placement="bottom-start"
|
||||
customButton={
|
||||
<div className="flex items-center gap-2 rounded-md border border-custom-border-400 px-2 py-1.5">
|
||||
<span className="flex flex-grow justify-center text-sm text-custom-text-200">{type}</span>
|
||||
<ChevronDown className="h-4 w-4 text-custom-text-400" />
|
||||
</div>
|
||||
}
|
||||
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||
closeOnSelect
|
||||
>
|
||||
<></>
|
||||
{tabsList.map((tab) => (
|
||||
<CustomMenu.MenuItem className="flex items-center gap-2" key={tab.route}>
|
||||
<Link
|
||||
key={tab.route}
|
||||
href={`/${workspaceSlug}/profile/${userId}/${tab.route}`}
|
||||
className="w-full text-custom-text-300"
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
<button
|
||||
className="block transition-all md:hidden"
|
||||
onClick={() => {
|
||||
toggleProfileSidebar();
|
||||
}}
|
||||
>
|
||||
<PanelRight
|
||||
className={cn(
|
||||
"block h-4 w-4 md:hidden",
|
||||
!profileSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default UserProfileHeader;
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// icons
|
||||
import { ChevronDown } from "lucide-react";
|
||||
// types
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues";
|
||||
// constants
|
||||
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue";
|
||||
// helpers
|
||||
import { calculateTotalFilters } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import { useIssues, useLabel } from "@/hooks/store";
|
||||
|
||||
const ProfileIssuesMobileHeader = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, userId } = useParams();
|
||||
// store hook
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.PROFILE);
|
||||
|
||||
const { workspaceLabels } = useLabel();
|
||||
// derived values
|
||||
const states = undefined;
|
||||
// const members = undefined;
|
||||
// const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
// const states = undefined;
|
||||
const members = undefined;
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: TIssueLayouts) => {
|
||||
if (!workspaceSlug || !userId) return;
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
undefined,
|
||||
EIssueFilterType.DISPLAY_FILTERS,
|
||||
{ layout: layout },
|
||||
userId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, updateFilters, userId]
|
||||
);
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !userId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
undefined,
|
||||
EIssueFilterType.FILTERS,
|
||||
{ [key]: newValues },
|
||||
userId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, issueFilters, updateFilters, userId]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !userId) return;
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
undefined,
|
||||
EIssueFilterType.DISPLAY_FILTERS,
|
||||
updatedDisplayFilter,
|
||||
userId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, updateFilters, userId]
|
||||
);
|
||||
|
||||
const handleDisplayProperties = useCallback(
|
||||
(property: Partial<IIssueDisplayProperties>) => {
|
||||
if (!workspaceSlug || !userId) return;
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
undefined,
|
||||
EIssueFilterType.DISPLAY_PROPERTIES,
|
||||
property,
|
||||
userId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, updateFilters, userId]
|
||||
);
|
||||
|
||||
const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0;
|
||||
|
||||
return (
|
||||
<div className="flex justify-evenly border-b border-custom-border-200 py-2 md:hidden">
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className="flex flex-grow justify-center text-sm text-custom-text-200"
|
||||
placement="bottom-start"
|
||||
customButton={<span className="flex flex-grow justify-center text-sm text-custom-text-200">Layout</span>}
|
||||
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||
closeOnSelect
|
||||
>
|
||||
{ISSUE_LAYOUTS.map((layout, index) => {
|
||||
if (layout.key === "spreadsheet" || layout.key === "gantt_chart" || layout.key === "calendar") return;
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={index}
|
||||
onClick={() => {
|
||||
handleLayoutChange(ISSUE_LAYOUTS[index].key);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<layout.icon className="h-3 w-3" />
|
||||
<div className="text-custom-text-300">{layout.title}</div>
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<FiltersDropdown
|
||||
title="Filters"
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<span className="flex items-center text-sm text-custom-text-200">
|
||||
Filters
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" />
|
||||
</span>
|
||||
}
|
||||
isFiltersApplied={isFiltersApplied}
|
||||
>
|
||||
<FilterSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.profile_issues[activeLayout] : undefined
|
||||
}
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
states={states}
|
||||
labels={workspaceLabels}
|
||||
memberIds={members}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<FiltersDropdown
|
||||
title="Display"
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<span className="flex items-center text-sm text-custom-text-200">
|
||||
Display
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.profile_issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProfileIssuesMobileHeader;
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
|
||||
import UserProfileHeader from "./header";
|
||||
|
||||
const ProfileOverviewHeader = () => <AppHeaderWrapper header={<UserProfileHeader type="Summary" />} />;
|
||||
|
||||
export default ProfileOverviewHeader;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
|
||||
import UserProfileHeader from "../header";
|
||||
import ProfileIssuesMobileHeader from "../mobile-header";
|
||||
|
||||
const ProfileSubscribedHeader = () => (
|
||||
<AppHeaderWrapper header={<UserProfileHeader type="Subscribed" />} mobileHeader={<ProfileIssuesMobileHeader />} />
|
||||
);
|
||||
|
||||
export default ProfileSubscribedHeader;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import DefaultProjectArchivesHeader from "../page";
|
||||
export default DefaultProjectArchivesHeader;
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { FC } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
// ui
|
||||
import { ArchiveIcon, Breadcrumbs, Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink, Logo } from "@/components/common";
|
||||
// constants
|
||||
import { PROJECT_ARCHIVES_BREADCRUMB_LIST } from "@/constants/archives";
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
// hooks
|
||||
import { useIssues, useProject } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
const ProjectArchivesHeader: FC = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const pathname = usePathname();
|
||||
const activeTab = pathname.split("/").pop();
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { issueFilters },
|
||||
} = useIssues(EIssuesStoreType.ARCHIVED);
|
||||
const { currentProjectDetails } = useProject();
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
const issueCount = currentProjectDetails
|
||||
? !issueFilters?.displayFilters?.sub_issue && currentProjectDetails.archived_sub_issues
|
||||
? currentProjectDetails.archived_issues - currentProjectDetails.archived_sub_issues
|
||||
: currentProjectDetails.archived_issues
|
||||
: undefined;
|
||||
|
||||
const activeTabBreadcrumbDetail =
|
||||
PROJECT_ARCHIVES_BREADCRUMB_LIST[activeTab as keyof typeof PROJECT_ARCHIVES_BREADCRUMB_LIST];
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-14 w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
|
||||
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Breadcrumbs onBack={router.back}>
|
||||
<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" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{activeTabBreadcrumbDetail && (
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={activeTabBreadcrumbDetail.label}
|
||||
icon={<activeTabBreadcrumbDetail.icon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Breadcrumbs>
|
||||
{activeTab === "issues" && issueCount && issueCount > 0 ? (
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "issues" : "issue"} in project's archived`}
|
||||
position="bottom"
|
||||
>
|
||||
<span className="cursor-default flex items-center text-center justify-center px-2.5 py-0.5 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
|
||||
{issueCount}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectArchivesHeader;
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// ui
|
||||
import { ArchiveIcon, Breadcrumbs, LayersIcon } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink, Logo } from "@/components/common";
|
||||
// constants
|
||||
import { ISSUE_DETAILS } from "@/constants/fetch-keys";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
// services
|
||||
import { IssueArchiveService } from "@/services/issue";
|
||||
|
||||
const issueArchiveService = new IssueArchiveService();
|
||||
|
||||
const ProjectArchivedIssueDetailsHeader: FC = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, projectId, archivedIssueId } = useParams();
|
||||
// store hooks
|
||||
const { currentProjectDetails } = useProject();
|
||||
|
||||
const { data: issueDetails } = useSWR(
|
||||
workspaceSlug && projectId && archivedIssueId ? ISSUE_DETAILS(archivedIssueId as string) : null,
|
||||
workspaceSlug && projectId && archivedIssueId
|
||||
? () =>
|
||||
issueArchiveService.retrieveArchivedIssue(
|
||||
workspaceSlug as string,
|
||||
projectId as string,
|
||||
archivedIssueId as string
|
||||
)
|
||||
: null
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-14 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>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectArchivedIssueDetailsHeader;
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
|
||||
import ProjectArchivedIssueDetailsHeader from "./header";
|
||||
|
||||
const ProjectArchivedIssueDetailsHeaderPage = () => <AppHeaderWrapper header={<ProjectArchivedIssueDetailsHeader />} />;
|
||||
|
||||
export default ProjectArchivedIssueDetailsHeaderPage;
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
|
||||
import ProjectArchivesHeader from "./header";
|
||||
|
||||
const ProjectArchivesHeaderPage = () => <AppHeaderWrapper header={<ProjectArchivesHeader />} />;
|
||||
|
||||
export default ProjectArchivesHeaderPage;
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
// icons
|
||||
import { ArrowRight, PanelRight } from "lucide-react";
|
||||
// types
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, ContrastIcon, CustomMenu, Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { ProjectAnalyticsModal } from "@/components/analytics";
|
||||
import { BreadcrumbLink, Logo } from "@/components/common";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
|
||||
// constants
|
||||
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { calculateTotalFilters } from "@/helpers/filter.helper";
|
||||
import { truncateText } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import {
|
||||
useEventTracker,
|
||||
useCycle,
|
||||
useLabel,
|
||||
useMember,
|
||||
useProject,
|
||||
useProjectState,
|
||||
useUser,
|
||||
useIssues,
|
||||
useCommandPalette,
|
||||
} from "@/hooks/store";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
const CycleDropdownOption: React.FC<{ cycleId: string }> = ({ cycleId }) => {
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const { getCycleById } = useCycle();
|
||||
// derived values
|
||||
const cycle = getCycleById(cycleId);
|
||||
//
|
||||
|
||||
if (!cycle) return null;
|
||||
|
||||
return (
|
||||
<CustomMenu.MenuItem key={cycle.id}>
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`} className="flex items-center gap-1.5">
|
||||
<ContrastIcon className="h-3 w-3" />
|
||||
{truncateText(cycle.name, 40)}
|
||||
</Link>
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const CycleIssuesHeader: React.FC = observer(() => {
|
||||
// states
|
||||
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId } = useParams() as {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
cycleId: string;
|
||||
};
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
issues: { issuesCount },
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
const { currentProjectCycleIds, getCycleById } = useCycle();
|
||||
const { toggleCreateIssueModal } = useCommandPalette();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
const { isMobile } = usePlatformOS();
|
||||
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", "false");
|
||||
|
||||
const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false;
|
||||
const toggleSidebar = () => {
|
||||
setValue(`${!isSidebarCollapsed}`);
|
||||
};
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: TIssueLayouts) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, cycleId);
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, updateFilters]
|
||||
);
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }, cycleId);
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, issueFilters, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, cycleId);
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayProperties = useCallback(
|
||||
(property: Partial<IIssueDisplayProperties>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property, cycleId);
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, updateFilters]
|
||||
);
|
||||
|
||||
// derived values
|
||||
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
|
||||
const isCompletedCycle = cycleDetails?.status?.toLocaleLowerCase() === "completed";
|
||||
const canUserCreateIssue =
|
||||
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
||||
|
||||
const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectAnalyticsModal
|
||||
isOpen={analyticsModal}
|
||||
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">
|
||||
<div className="flex items-center gap-2">
|
||||
<Breadcrumbs onBack={router.back}>
|
||||
<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
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
|
||||
className="block pl-2 text-custom-text-300 md:hidden"
|
||||
>
|
||||
...
|
||||
</Link>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label="Cycles"
|
||||
href={`/${workspaceSlug}/projects/${projectId}/cycles`}
|
||||
icon={<ContrastIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="component"
|
||||
component={
|
||||
<CustomMenu
|
||||
label={
|
||||
<>
|
||||
<ContrastIcon 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">{cycleDetails?.name && cycleDetails.name}</p>
|
||||
{issuesCount && issuesCount > 0 ? (
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={`There are ${issuesCount} ${issuesCount > 1 ? "issues" : "issue"
|
||||
} in this cycle`}
|
||||
position="bottom"
|
||||
>
|
||||
<span className="flex flex-shrink-0 cursor-default items-center justify-center rounded-xl bg-custom-primary-100/20 px-2 text-center text-xs font-semibold text-custom-primary-100">
|
||||
{issuesCount}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
className="ml-1.5 flex-shrink-0 truncate"
|
||||
placement="bottom-start"
|
||||
>
|
||||
{currentProjectCycleIds?.map((cycleId) => <CycleDropdownOption key={cycleId} cycleId={cycleId} />)}
|
||||
</CustomMenu>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
<div className="hidden items-center gap-2 md:flex ">
|
||||
<LayoutSelection
|
||||
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
<FiltersDropdown title="Filters" placement="bottom-end" isFiltersApplied={isFiltersApplied}>
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
<FiltersDropdown title="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={["cycle"]}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
|
||||
{canUserCreateIssue && (
|
||||
<>
|
||||
<Button onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
|
||||
Analytics
|
||||
</Button>
|
||||
{!isCompletedCycle && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTrackElement("Cycle issues page");
|
||||
toggleCreateIssueModal(true, EIssuesStoreType.CYCLE);
|
||||
}}
|
||||
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={`h-4 w-4 duration-300 ${isSidebarCollapsed ? "-rotate-180" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="grid h-7 w-7 place-items-center rounded p-1 outline-none hover:bg-custom-sidebar-background-80 md:hidden"
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<PanelRight className={cn("h-4 w-4", !isSidebarCollapsed ? "text-[#3E63DD]" : "text-custom-text-200")} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default CycleIssuesHeader;
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
// icons
|
||||
import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
|
||||
// types
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
import { ProjectAnalyticsModal } from "@/components/analytics";
|
||||
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues";
|
||||
// constants
|
||||
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue";
|
||||
// helpers
|
||||
import { calculateTotalFilters } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import { useIssues, useCycle, useProjectState, useLabel, useMember, useProject } from "@/hooks/store";
|
||||
|
||||
const CycleIssuesMobileHeader = () => {
|
||||
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||
const { getCycleById } = useCycle();
|
||||
const layouts = [
|
||||
{ key: "list", title: "List", icon: List },
|
||||
{ key: "kanban", title: "Kanban", icon: Kanban },
|
||||
{ key: "calendar", title: "Calendar", icon: Calendar },
|
||||
];
|
||||
|
||||
const { workspaceSlug, projectId, cycleId } = useParams();
|
||||
const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined;
|
||||
// store hooks
|
||||
const { currentProjectDetails } = useProject();
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.CYCLE);
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: TIssueLayouts) => {
|
||||
if (!workspaceSlug || !projectId || !cycleId) return;
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.DISPLAY_FILTERS,
|
||||
{ layout: layout },
|
||||
cycleId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, updateFilters]
|
||||
);
|
||||
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId || !cycleId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.FILTERS,
|
||||
{ [key]: newValues },
|
||||
cycleId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, issueFilters, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !projectId || !cycleId) return;
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.DISPLAY_FILTERS,
|
||||
updatedDisplayFilter,
|
||||
cycleId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayProperties = useCallback(
|
||||
(property: Partial<IIssueDisplayProperties>) => {
|
||||
if (!workspaceSlug || !projectId || !cycleId) return;
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.DISPLAY_PROPERTIES,
|
||||
property,
|
||||
cycleId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, updateFilters]
|
||||
);
|
||||
|
||||
const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectAnalyticsModal
|
||||
isOpen={analyticsModal}
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
cycleDetails={cycleDetails ?? undefined}
|
||||
/>
|
||||
<div className="flex justify-evenly py-2 border-b border-custom-border-200 md:hidden bg-custom-background-100">
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||
placement="bottom-start"
|
||||
customButton={<span className="flex flex-grow justify-center text-custom-text-200 text-sm">Layout</span>}
|
||||
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||
closeOnSelect
|
||||
>
|
||||
{layouts.map((layout, index) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={ISSUE_LAYOUTS[index].key}
|
||||
onClick={() => {
|
||||
handleLayoutChange(ISSUE_LAYOUTS[index].key);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<layout.icon className="w-3 h-3" />
|
||||
<div className="text-custom-text-300">{layout.title}</div>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
<div className="flex flex-grow justify-center border-l border-custom-border-200 items-center text-custom-text-200 text-sm">
|
||||
<FiltersDropdown
|
||||
title="Filters"
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<span className="flex items-center text-custom-text-200 text-sm">
|
||||
Filters
|
||||
<ChevronDown className="text-custom-text-200 h-4 w-4 ml-2" />
|
||||
</span>
|
||||
}
|
||||
isFiltersApplied={isFiltersApplied}
|
||||
>
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
labels={projectLabels}
|
||||
memberIds={projectMemberIds ?? undefined}
|
||||
states={projectStates}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
<div className="flex flex-grow justify-center border-l border-custom-border-200 items-center text-custom-text-200 text-sm">
|
||||
<FiltersDropdown
|
||||
title="Display"
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<span className="flex items-center text-custom-text-200 text-sm">
|
||||
Display
|
||||
<ChevronDown className="text-custom-text-200 h-4 w-4 ml-2" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||
ignoreGroupedFilters={["cycle"]}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
|
||||
<span
|
||||
onClick={() => setAnalyticsModal(true)}
|
||||
className="flex flex-grow justify-center text-custom-text-200 text-sm border-l border-custom-border-200"
|
||||
>
|
||||
Analytics
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CycleIssuesMobileHeader;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
|
||||
import CycleIssuesHeader from "./header";
|
||||
import CycleIssuesMobileHeader from "./mobile-header";
|
||||
|
||||
const CycleIssuesHeaderPage = () => (
|
||||
<AppHeaderWrapper header={<CycleIssuesHeader />} mobileHeader={<CycleIssuesMobileHeader />} />
|
||||
);
|
||||
|
||||
export default CycleIssuesHeaderPage;
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { FC } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, ContrastIcon } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink, Logo } from "@/components/common";
|
||||
import { CyclesViewHeader } from "@/components/cycles";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// hooks
|
||||
import { useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store";
|
||||
|
||||
const CyclesHeader: FC = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { toggleCreateCycleModal } = useCommandPalette();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { currentProjectDetails } = useProject();
|
||||
|
||||
const canUserCreateCycle =
|
||||
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}>
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
export default CyclesHeader;
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { List } from "lucide-react";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// icon
|
||||
// constants
|
||||
import { CYCLE_VIEW_LAYOUTS } from "@/constants/cycle";
|
||||
// hooks
|
||||
import { useCycleFilter, useProject } from "@/hooks/store";
|
||||
|
||||
const CyclesListMobileHeader = observer(() => {
|
||||
const { currentProjectDetails } = useProject();
|
||||
// hooks
|
||||
const { updateDisplayFilters } = useCycleFilter();
|
||||
return (
|
||||
<div className="flex justify-center sm:hidden">
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className="flex flex-grow justify-center text-custom-text-200 text-sm py-2 border-b border-custom-border-200 bg-custom-sidebar-background-100"
|
||||
// placement="bottom-start"
|
||||
customButton={
|
||||
<span className="flex items-center gap-2">
|
||||
<List className="h-4 w-4" />
|
||||
<span className="flex flex-grow justify-center text-custom-text-200 text-sm">Layout</span>
|
||||
</span>
|
||||
}
|
||||
customButtonClassName="flex flex-grow justify-center items-center text-custom-text-200 text-sm"
|
||||
closeOnSelect
|
||||
>
|
||||
{CYCLE_VIEW_LAYOUTS.map((layout) => {
|
||||
if (layout.key == "gantt") return;
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={layout.key}
|
||||
onClick={() => {
|
||||
updateDisplayFilters(currentProjectDetails!.id, {
|
||||
layout: layout.key,
|
||||
});
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<layout.icon className="w-3 h-3" />
|
||||
<div className="text-custom-text-300">{layout.title}</div>
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default CyclesListMobileHeader;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
|
||||
import CyclesHeader from "./header";
|
||||
import CyclesListMobileHeader from "./mobile-header";
|
||||
|
||||
const CyclesHeaderPage = () => (
|
||||
<AppHeaderWrapper header={<CyclesHeader />} mobileHeader={<CyclesListMobileHeader />} />
|
||||
);
|
||||
|
||||
export default CyclesHeaderPage;
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import { FC, useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// types
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink, Logo } from "@/components/common";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
|
||||
// constants
|
||||
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
|
||||
// helpers
|
||||
import { calculateTotalFilters } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import { useIssues, useLabel, useMember, useProject, useProjectState } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
const ProjectDraftIssueHeader: FC = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string };
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.DRAFT);
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues });
|
||||
},
|
||||
[workspaceSlug, projectId, issueFilters, updateFilters]
|
||||
);
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: TIssueLayouts) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout });
|
||||
},
|
||||
[workspaceSlug, projectId, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter);
|
||||
},
|
||||
[workspaceSlug, projectId, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayProperties = useCallback(
|
||||
(property: Partial<IIssueDisplayProperties>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property);
|
||||
},
|
||||
[workspaceSlug, projectId, updateFilters]
|
||||
);
|
||||
|
||||
const issueCount = currentProjectDetails
|
||||
? !issueFilters?.displayFilters?.sub_issue && currentProjectDetails.draft_sub_issues
|
||||
? currentProjectDetails.draft_issues - currentProjectDetails.draft_sub_issues
|
||||
: currentProjectDetails.draft_issues
|
||||
: undefined;
|
||||
|
||||
const isFiltersApplied = calculateTotalFilters(issueFilters?.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 w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Breadcrumbs>
|
||||
<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 label="Draft Issues" icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} />
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
{issueCount && issueCount > 0 ? (
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={`There are ${issueCount} ${issueCount > 1 ? "issues" : "issue"} in project's draft`}
|
||||
position="bottom"
|
||||
>
|
||||
<span className="cursor-default flex items-center text-center justify-center px-2.5 py-0.5 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
|
||||
{issueCount}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<LayoutSelection
|
||||
layouts={["list", "kanban"]}
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
<FiltersDropdown title="Filters" placement="bottom-end" isFiltersApplied={isFiltersApplied}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectDraftIssueHeader;
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
|
||||
import ProjectDraftIssueHeader from "./header";
|
||||
|
||||
const ProjectDraftIssueHeaderPage = () => <AppHeaderWrapper header={<ProjectDraftIssueHeader />} />;
|
||||
|
||||
export default ProjectDraftIssueHeaderPage;
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { FC, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { RefreshCcw } from "lucide-react";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, LayersIcon } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink, Logo } from "@/components/common";
|
||||
import { InboxIssueCreateEditModalRoot } from "@/components/inbox";
|
||||
// hooks
|
||||
import { useProject, useProjectInbox } from "@/hooks/store";
|
||||
|
||||
const ProjectInboxHeader: FC = observer(() => {
|
||||
// states
|
||||
const [createIssueModal, setCreateIssueModal] = useState(false);
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { loader } = useProjectInbox();
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
|
||||
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||
<div className="flex items-center gap-4">
|
||||
<Breadcrumbs>
|
||||
<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 label="Inbox" icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} />
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
|
||||
{loader === "pagination-loading" && (
|
||||
<div className="flex items-center gap-1.5 text-custom-text-300">
|
||||
<RefreshCcw className="h-3.5 w-3.5 animate-spin" />
|
||||
<p className="text-sm">Syncing...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentProjectDetails?.inbox_view && workspaceSlug && projectId && (
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectInboxHeader;
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
|
||||
import ProjectInboxHeader from "./header";
|
||||
|
||||
const ProjectInboxHeaderPage = () => <AppHeaderWrapper header={<ProjectInboxHeader />} />;
|
||||
|
||||
export default ProjectInboxHeaderPage;
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { PanelRight } from "lucide-react";
|
||||
// ui
|
||||
import { Breadcrumbs, LayersIcon } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink, Logo } from "@/components/common";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store";
|
||||
|
||||
const ProjectIssueDetailsHeader: FC = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = useParams();
|
||||
// store hooks
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { issueDetailSidebarCollapsed, toggleIssueDetailSidebar } = useAppTheme();
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
// derived values
|
||||
const issueDetails = issueId ? getIssueById(issueId.toString()) : undefined;
|
||||
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">
|
||||
<div>
|
||||
<Breadcrumbs onBack={router.back}>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects`}
|
||||
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
|
||||
href={`/${workspaceSlug}/projects/${projectId}/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>
|
||||
<button className="block md:hidden" onClick={() => toggleIssueDetailSidebar()}>
|
||||
<PanelRight
|
||||
className={cn("h-4 w-4 ", !isSidebarCollapsed ? "text-custom-primary-100" : " text-custom-text-200")}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectIssueDetailsHeader;
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
|
||||
import ProjectIssueDetailsHeader from "./header";
|
||||
|
||||
const ProjectIssueDetailsHeaderPage = () => <AppHeaderWrapper header={<ProjectIssueDetailsHeader />} />;
|
||||
|
||||
export default ProjectIssueDetailsHeaderPage;
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
// icons
|
||||
import { Briefcase, Circle, ExternalLink } from "lucide-react";
|
||||
// types
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, LayersIcon, Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { ProjectAnalyticsModal } from "@/components/analytics";
|
||||
import { BreadcrumbLink, Logo } from "@/components/common";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
|
||||
// constants
|
||||
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { SPACE_BASE_URL } from "@/helpers/common.helper";
|
||||
import { calculateTotalFilters } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import {
|
||||
useEventTracker,
|
||||
useLabel,
|
||||
useProject,
|
||||
useProjectState,
|
||||
useUser,
|
||||
useMember,
|
||||
useCommandPalette,
|
||||
} from "@/hooks/store";
|
||||
import { useIssues } from "@/hooks/store/use-issues";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
const ProjectIssuesHeader: React.FC = observer(() => {
|
||||
// states
|
||||
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string };
|
||||
// store hooks
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
issues: { issuesCount },
|
||||
} = useIssues(EIssuesStoreType.PROJECT);
|
||||
const { toggleCreateIssueModal } = useCommandPalette();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
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: TIssueLayouts) => {
|
||||
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 publishedURL = `${SPACE_BASE_URL}/issues/${currentProjectDetails?.anchor}`;
|
||||
|
||||
const canUserCreateIssue =
|
||||
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
||||
|
||||
const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0;
|
||||
|
||||
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">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Breadcrumbs onBack={() => router.back()}>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects`}
|
||||
label={currentProjectDetails?.name ?? "Project"}
|
||||
icon={
|
||||
currentProjectDetails ? (
|
||||
currentProjectDetails && (
|
||||
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
|
||||
<Logo logo={currentProjectDetails?.logo_props} size={16} />
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="grid h-7 w-7 flex-shrink-0 place-items-center rounded uppercase">
|
||||
<Briefcase className="h-4 w-4" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={<BreadcrumbLink label="Issues" icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
{issuesCount && issuesCount > 0 ? (
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipContent={`There are ${issuesCount} ${issuesCount > 1 ? "issues" : "issue"} in this project`}
|
||||
position="bottom"
|
||||
>
|
||||
<span className="cursor-default flex items-center text-center justify-center px-2.5 py-0.5 flex-shrink-0 bg-custom-primary-100/20 text-custom-primary-100 text-xs font-semibold rounded-xl">
|
||||
{issuesCount}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
{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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Circle className="h-1.5 w-1.5 fill-custom-primary-100" strokeWidth={2} />
|
||||
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={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
<FiltersDropdown title="Filters" placement="bottom-end" isFiltersApplied={isFiltersApplied}>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{canUserCreateIssue && (
|
||||
<>
|
||||
<Button
|
||||
className="hidden md:block"
|
||||
onClick={() => setAnalyticsModal(true)}
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
>
|
||||
Analytics
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTrackElement("Project issues page");
|
||||
toggleCreateIssueModal(true, EIssuesStoreType.PROJECT);
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
<div className="hidden sm:block">Add</div> Issue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectIssuesHeader;
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// icons
|
||||
import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
|
||||
// types
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
import { ProjectAnalyticsModal } from "@/components/analytics";
|
||||
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues/issue-layouts";
|
||||
// constants
|
||||
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue";
|
||||
// helpers
|
||||
import { calculateTotalFilters } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import { useIssues, useLabel, useMember, useProject, useProjectState } from "@/hooks/store";
|
||||
|
||||
const ProjectIssuesMobileHeader = observer(() => {
|
||||
const layouts = [
|
||||
{ key: "list", title: "List", icon: List },
|
||||
{ key: "kanban", title: "Kanban", icon: Kanban },
|
||||
{ key: "calendar", title: "Calendar", icon: Calendar },
|
||||
];
|
||||
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||
const { workspaceSlug, projectId } = useParams() as {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
};
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.PROJECT);
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: TIssueLayouts) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout });
|
||||
},
|
||||
[workspaceSlug, projectId, updateFilters]
|
||||
);
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues });
|
||||
},
|
||||
[workspaceSlug, projectId, issueFilters, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter);
|
||||
},
|
||||
[workspaceSlug, projectId, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayProperties = useCallback(
|
||||
(property: Partial<IIssueDisplayProperties>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property);
|
||||
},
|
||||
[workspaceSlug, projectId, updateFilters]
|
||||
);
|
||||
|
||||
const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectAnalyticsModal
|
||||
isOpen={analyticsModal}
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
projectDetails={currentProjectDetails ?? undefined}
|
||||
/>
|
||||
<div className="md:hidden flex justify-evenly border-b border-custom-border-200 py-2 z-[13] bg-custom-background-100">
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className="flex flex-grow justify-center text-sm text-custom-text-200"
|
||||
placement="bottom-start"
|
||||
customButton={<span className="flex flex-grow justify-center text-sm text-custom-text-200">Layout</span>}
|
||||
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||
closeOnSelect
|
||||
>
|
||||
{layouts.map((layout, index) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={index}
|
||||
onClick={() => {
|
||||
handleLayoutChange(ISSUE_LAYOUTS[index].key);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<layout.icon className="h-3 w-3" />
|
||||
<div className="text-custom-text-300">{layout.title}</div>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<FiltersDropdown
|
||||
title="Filters"
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<span className="flex items-center text-sm text-custom-text-200">
|
||||
Filters
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" />
|
||||
</span>
|
||||
}
|
||||
isFiltersApplied={isFiltersApplied}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<FiltersDropdown
|
||||
title="Display"
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<span className="flex items-center text-sm text-custom-text-200">
|
||||
Display
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setAnalyticsModal(true)}
|
||||
className="flex flex-grow justify-center border-l border-custom-border-200 text-sm text-custom-text-200"
|
||||
>
|
||||
Analytics
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectIssuesMobileHeader;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
|
||||
import ProjectIssuesHeader from "./header";
|
||||
import ProjectIssuesMobileHeader from "./mobile-header";
|
||||
|
||||
const ProjectIssuesHeaderPage = () => (
|
||||
<AppHeaderWrapper header={<ProjectIssuesHeader />} mobileHeader={<ProjectIssuesMobileHeader />} />
|
||||
);
|
||||
|
||||
export default ProjectIssuesHeaderPage;
|
||||
|
|
@ -0,0 +1,312 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
// icons
|
||||
import { ArrowRight, PanelRight } from "lucide-react";
|
||||
// types
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, CustomMenu, DiceIcon, Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { ProjectAnalyticsModal } from "@/components/analytics";
|
||||
import { BreadcrumbLink, Logo } from "@/components/common";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
|
||||
// constants
|
||||
import { EIssuesStoreType, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { calculateTotalFilters } from "@/helpers/filter.helper";
|
||||
import { truncateText } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import {
|
||||
useEventTracker,
|
||||
useLabel,
|
||||
useMember,
|
||||
useModule,
|
||||
useProject,
|
||||
useProjectState,
|
||||
useUser,
|
||||
useIssues,
|
||||
useCommandPalette,
|
||||
} from "@/hooks/store";
|
||||
import { useIssuesActions } from "@/hooks/use-issues-actions";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
const ModuleDropdownOption: React.FC<{ moduleId: string }> = ({ moduleId }) => {
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const { getModuleById } = useModule();
|
||||
// derived values
|
||||
const moduleDetail = getModuleById(moduleId);
|
||||
|
||||
if (!moduleDetail) return null;
|
||||
|
||||
return (
|
||||
<CustomMenu.MenuItem key={moduleDetail.id}>
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/modules/${moduleDetail.id}`}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<DiceIcon className="h-3 w-3" />
|
||||
{truncateText(moduleDetail.name, 40)}
|
||||
</Link>
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
// states
|
||||
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, moduleId } = useParams();
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { issueFilters },
|
||||
issues: { issuesCount },
|
||||
} = useIssues(EIssuesStoreType.MODULE);
|
||||
const { updateFilters } = useIssuesActions(EIssuesStoreType.MODULE);
|
||||
const { projectModuleIds, getModuleById } = useModule();
|
||||
const { toggleCreateIssueModal } = useCommandPalette();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { projectLabels } = useLabel();
|
||||
const { projectStates } = useProjectState();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
|
||||
const { setValue, storedValue } = useLocalStorage("module_sidebar_collapsed", "false");
|
||||
|
||||
const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false;
|
||||
const toggleSidebar = () => {
|
||||
setValue(`${!isSidebarCollapsed}`);
|
||||
};
|
||||
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: TIssueLayouts) => {
|
||||
if (!projectId) return;
|
||||
updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_FILTERS, { layout: layout });
|
||||
},
|
||||
[projectId, moduleId, updateFilters]
|
||||
);
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!projectId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(projectId.toString(), EIssueFilterType.FILTERS, { [key]: newValues });
|
||||
},
|
||||
[projectId, moduleId, issueFilters, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!projectId) return;
|
||||
updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter);
|
||||
},
|
||||
[projectId, moduleId, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayProperties = useCallback(
|
||||
(property: Partial<IIssueDisplayProperties>) => {
|
||||
if (!projectId) return;
|
||||
updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_PROPERTIES, property);
|
||||
},
|
||||
[projectId, moduleId, updateFilters]
|
||||
);
|
||||
|
||||
// derived values
|
||||
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
|
||||
const canUserCreateIssue =
|
||||
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
||||
|
||||
const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectAnalyticsModal
|
||||
isOpen={analyticsModal}
|
||||
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}>
|
||||
<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
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
|
||||
className="block pl-2 text-custom-text-300 md:hidden"
|
||||
>
|
||||
...
|
||||
</Link>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/modules`}
|
||||
label="Modules"
|
||||
icon={<DiceIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="component"
|
||||
component={
|
||||
<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"
|
||||
>
|
||||
{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={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
<FiltersDropdown title="Filters" placement="bottom-end" isFiltersApplied={isFiltersApplied}>
|
||||
<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}
|
||||
ignoreGroupedFilters={["module"]}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
|
||||
{canUserCreateIssue && (
|
||||
<>
|
||||
<Button
|
||||
className="hidden md:block"
|
||||
onClick={() => setAnalyticsModal(true)}
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
>
|
||||
Analytics
|
||||
</Button>
|
||||
<Button
|
||||
className="hidden sm:flex"
|
||||
onClick={() => {
|
||||
setTrackElement("Module 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ModuleIssuesHeader;
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// icons
|
||||
import { Calendar, ChevronDown, Kanban, List } from "lucide-react";
|
||||
// types
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
import { ProjectAnalyticsModal } from "@/components/analytics";
|
||||
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues";
|
||||
// constants
|
||||
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue";
|
||||
// helpers
|
||||
import { calculateTotalFilters } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import { useIssues, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store";
|
||||
|
||||
const ModuleIssuesMobileHeader = observer(() => {
|
||||
const [analyticsModal, setAnalyticsModal] = useState(false);
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { getModuleById } = useModule();
|
||||
const layouts = [
|
||||
{ key: "list", title: "List", icon: List },
|
||||
{ key: "kanban", title: "Kanban", icon: Kanban },
|
||||
{ key: "calendar", title: "Calendar", icon: Calendar },
|
||||
];
|
||||
const { workspaceSlug, projectId, moduleId } = useParams() as {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
moduleId: string;
|
||||
};
|
||||
const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined;
|
||||
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.MODULE);
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: TIssueLayouts) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, moduleId);
|
||||
},
|
||||
[workspaceSlug, projectId, moduleId, updateFilters]
|
||||
);
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }, moduleId);
|
||||
},
|
||||
[workspaceSlug, projectId, moduleId, issueFilters, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, moduleId);
|
||||
},
|
||||
[workspaceSlug, projectId, moduleId, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayProperties = useCallback(
|
||||
(property: Partial<IIssueDisplayProperties>) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property, moduleId);
|
||||
},
|
||||
[workspaceSlug, projectId, moduleId, updateFilters]
|
||||
);
|
||||
|
||||
const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0;
|
||||
|
||||
return (
|
||||
<div className="block md:hidden">
|
||||
<ProjectAnalyticsModal
|
||||
isOpen={analyticsModal}
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
moduleDetails={moduleDetails ?? undefined}
|
||||
/>
|
||||
<div className="flex justify-evenly border-b border-custom-border-200 bg-custom-background-100 py-2">
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className="flex flex-grow justify-center text-sm text-custom-text-200"
|
||||
placement="bottom-start"
|
||||
customButton={<span className="flex flex-grow justify-center text-sm text-custom-text-200">Layout</span>}
|
||||
customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm"
|
||||
closeOnSelect
|
||||
>
|
||||
{layouts.map((layout, index) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={layout.key}
|
||||
onClick={() => {
|
||||
handleLayoutChange(ISSUE_LAYOUTS[index].key);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<layout.icon className="h-3 w-3" />
|
||||
<div className="text-custom-text-300">{layout.title}</div>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<FiltersDropdown
|
||||
title="Filters"
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<span className="flex items-center text-sm text-custom-text-200">
|
||||
Filters
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" />
|
||||
</span>
|
||||
}
|
||||
isFiltersApplied={isFiltersApplied}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex flex-grow items-center justify-center border-l border-custom-border-200 text-sm text-custom-text-200">
|
||||
<FiltersDropdown
|
||||
title="Display"
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<span className="flex items-center text-sm text-custom-text-200">
|
||||
Display
|
||||
<ChevronDown className="ml-2 h-4 w-4 text-custom-text-200" />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={
|
||||
activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT.issues[activeLayout] : undefined
|
||||
}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||
ignoreGroupedFilters={["module"]}
|
||||
cycleViewDisabled={!currentProjectDetails?.cycle_view}
|
||||
moduleViewDisabled={!currentProjectDetails?.module_view}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setAnalyticsModal(true)}
|
||||
className="flex flex-grow justify-center border-l border-custom-border-200 text-sm text-custom-text-200"
|
||||
>
|
||||
Analytics
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ModuleIssuesMobileHeader;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
|
||||
import ModuleIssuesHeader from "./header";
|
||||
import ModuleIssuesMobileHeader from "./mobile-header";
|
||||
|
||||
const ModuleIssuesHeaderPage = () => (
|
||||
<AppHeaderWrapper header={<ModuleIssuesHeader />} mobileHeader={<ModuleIssuesMobileHeader />} />
|
||||
);
|
||||
|
||||
export default ModuleIssuesHeaderPage;
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { observer } from "mobx-react-lite";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, DiceIcon } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink, Logo } from "@/components/common";
|
||||
import { ModuleViewHeader } from "@/components/modules";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// hooks
|
||||
import { useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store";
|
||||
|
||||
const ModulesListHeader: React.FC = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { toggleCreateModuleModal } = useCommandPalette();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { currentProjectDetails } = useProject();
|
||||
|
||||
// auth
|
||||
const canUserCreateModule =
|
||||
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}>
|
||||
<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="Modules" icon={<DiceIcon className="h-4 w-4 text-custom-text-300" />} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ModuleViewHeader />
|
||||
{canUserCreateModule && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTrackElement("Modules page");
|
||||
toggleCreateModuleModal(true);
|
||||
}}
|
||||
>
|
||||
<div className="hidden sm:block">Add</div> Module
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ModulesListHeader;
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { observer } from "mobx-react";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { MODULE_VIEW_LAYOUTS } from "@/constants/module";
|
||||
import { useModuleFilter, useProject } from "@/hooks/store";
|
||||
|
||||
const ModulesListMobileHeader = observer(() => {
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { updateDisplayFilters } = useModuleFilter();
|
||||
|
||||
return (
|
||||
<div className="flex justify-center md:hidden">
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className="flex flex-grow justify-center text-custom-text-200 text-sm py-2 border-b border-custom-border-200 bg-custom-sidebar-background-100"
|
||||
// placement="bottom-start"
|
||||
customButton={<span className="flex flex-grow justify-center text-custom-text-200 text-sm">Layout</span>}
|
||||
customButtonClassName="flex flex-grow justify-center items-center text-custom-text-200 text-sm"
|
||||
closeOnSelect
|
||||
>
|
||||
{MODULE_VIEW_LAYOUTS.map((layout) => {
|
||||
if (layout.key == "gantt") return;
|
||||
return (
|
||||
<CustomMenu.MenuItem
|
||||
key={layout.key}
|
||||
onClick={() => {
|
||||
updateDisplayFilters(currentProjectDetails!.id.toString(), { layout: layout.key });
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<layout.icon className="w-3 h-3" />
|
||||
<div className="text-custom-text-300">{layout.title}</div>
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ModulesListMobileHeader;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
|
||||
import ModulesListHeader from "./header";
|
||||
import ModulesListMobileHeader from "./mobile-header";
|
||||
|
||||
const ModulesHeaderPage = () => (
|
||||
<AppHeaderWrapper header={<ModulesListHeader />} mobileHeader={<ModulesListMobileHeader />} />
|
||||
);
|
||||
|
||||
export default ModulesHeaderPage;
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { FileText } from "lucide-react";
|
||||
// types
|
||||
import { TLogoProps } from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, EmojiIconPicker, EmojiIconPickerTypes, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink, Logo } from "@/components/common";
|
||||
// helper
|
||||
import { convertHexEmojiToDecimal } from "@/helpers/emoji.helper";
|
||||
// hooks
|
||||
import { usePage, useProject } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
export interface IPagesHeaderProps {
|
||||
showButton?: boolean;
|
||||
}
|
||||
|
||||
const PageDetailsHeader = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, pageId } = useParams();
|
||||
// state
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
// store hooks
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { isContentEditable, isSubmitting, name, logo_props, updatePageLogo } = usePage(pageId?.toString() ?? "");
|
||||
|
||||
const handlePageLogoUpdate = async (data: TLogoProps) => {
|
||||
if (data) {
|
||||
updatePageLogo(data)
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Logo Updated successfully.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Something went wrong. Please try again.",
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
// use platform
|
||||
const { platform } = usePlatformOS();
|
||||
// derived values
|
||||
const isMac = platform === "MacOS";
|
||||
|
||||
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>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<span>
|
||||
<span className="hidden md:block">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
<span className="md:hidden">
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
|
||||
label={"..."}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/pages`}
|
||||
label="Pages"
|
||||
icon={<FileText className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
label={name ?? "Page"}
|
||||
icon={
|
||||
<EmojiIconPicker
|
||||
isOpen={isOpen}
|
||||
handleToggle={(val: boolean) => setIsOpen(val)}
|
||||
className="flex items-center justify-center"
|
||||
buttonClassName="flex items-center justify-center"
|
||||
label={
|
||||
<>
|
||||
{logo_props?.in_use ? (
|
||||
<Logo logo={logo_props} size={16} type="lucide" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 text-custom-text-300" />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
onChange={(val) => {
|
||||
let logoValue = {};
|
||||
|
||||
if (val?.type === "emoji")
|
||||
logoValue = {
|
||||
value: convertHexEmojiToDecimal(val.value.unified),
|
||||
url: val.value.imageUrl,
|
||||
};
|
||||
else if (val?.type === "icon") logoValue = val.value;
|
||||
|
||||
handlePageLogoUpdate({
|
||||
in_use: val?.type,
|
||||
[val?.type]: logoValue,
|
||||
}).finally(() => setIsOpen(false));
|
||||
}}
|
||||
defaultIconColor={
|
||||
logo_props?.in_use && logo_props.in_use === "icon" ? logo_props?.icon?.color : undefined
|
||||
}
|
||||
defaultOpen={
|
||||
logo_props?.in_use && logo_props?.in_use === "emoji"
|
||||
? EmojiIconPickerTypes.EMOJI
|
||||
: EmojiIconPickerTypes.ICON
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</div>
|
||||
{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"
|
||||
loading={isSubmitting === "submitting"}
|
||||
>
|
||||
{isSubmitting === "submitting" ? "Saving" : "Save changes"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default PageDetailsHeader;
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
|
||||
import PageDetailsHeader from "./header";
|
||||
|
||||
const PageDetailsHeaderPage = () => <AppHeaderWrapper header={<PageDetailsHeader />} />;
|
||||
|
||||
export default PageDetailsHeaderPage;
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { observer } from "mobx-react";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { FileText } from "lucide-react";
|
||||
// ui
|
||||
import { Breadcrumbs, Button } from "@plane/ui";
|
||||
// helpers
|
||||
import { BreadcrumbLink, Logo } from "@/components/common";
|
||||
// constants
|
||||
import { EPageAccess } from "@/constants/page";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// hooks
|
||||
import { useCommandPalette, useEventTracker, useProject, useUser } from "@/hooks/store";
|
||||
|
||||
const PagesHeader = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const pageType = searchParams.get("type");
|
||||
// store hooks
|
||||
const { toggleCreatePageModal } = useCommandPalette();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
|
||||
const canUserCreatePage =
|
||||
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>
|
||||
<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="Pages" icon={<FileText className="h-4 w-4 text-custom-text-300" />} />}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
});
|
||||
|
||||
export default PagesHeader;
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
|
||||
import PagesHeader from "./header";
|
||||
|
||||
const PagesHeaderPage = () => <AppHeaderWrapper header={<PagesHeader />} />;
|
||||
|
||||
export default PagesHeaderPage;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import DefaultProjectSettingHeader from "../page";
|
||||
export default DefaultProjectSettingHeader;
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
// ui
|
||||
import { Settings } from "lucide-react";
|
||||
import { Breadcrumbs, CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink, Logo } from "@/components/common";
|
||||
// constants
|
||||
import { EUserProjectRoles, PROJECT_SETTINGS_LINKS } from "@/constants/project";
|
||||
// hooks
|
||||
import { useProject, useUser } from "@/hooks/store";
|
||||
|
||||
const ProjectSettingHeader: FC = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { currentProjectDetails } = useProject();
|
||||
|
||||
if (currentProjectRole && currentProjectRole <= EUserProjectRoles.VIEWER) return null;
|
||||
|
||||
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>
|
||||
<div className="z-50">
|
||||
<Breadcrumbs onBack={router.back}>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`}
|
||||
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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div className="hidden sm:hidden md:block lg:block">
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink label="Settings" icon={<Settings className="h-4 w-4 text-custom-text-300" />} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</div>
|
||||
<CustomMenu
|
||||
className="flex-shrink-0 block sm:block md:hidden lg:hidden"
|
||||
maxHeight="lg"
|
||||
customButton={
|
||||
<span className="text-xs px-1.5 py-1 border rounded-md text-custom-text-200 border-custom-border-300">
|
||||
Settings
|
||||
</span>
|
||||
}
|
||||
placement="bottom-start"
|
||||
closeOnSelect
|
||||
>
|
||||
{PROJECT_SETTINGS_LINKS.map((item) => (
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
onClick={() => router.push(`/${workspaceSlug}/projects/${projectId}${item.href}`)}
|
||||
>
|
||||
{item.label}
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectSettingHeader;
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
|
||||
import ProjectSettingHeader from "./header";
|
||||
|
||||
const ProjectSettingHeaderPage = () => <AppHeaderWrapper header={<ProjectSettingHeader />} />;
|
||||
|
||||
export default ProjectSettingHeaderPage;
|
||||
|
|
@ -0,0 +1,259 @@
|
|||
import { useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
// types
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, CustomMenu, PhotoFilterIcon } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink, Logo } from "@/components/common";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
|
||||
// constants
|
||||
import { EIssuesStoreType, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// helpers
|
||||
import { calculateTotalFilters } from "@/helpers/filter.helper";
|
||||
import { truncateText } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import {
|
||||
useCommandPalette,
|
||||
useEventTracker,
|
||||
useIssues,
|
||||
useLabel,
|
||||
useMember,
|
||||
useProject,
|
||||
useProjectState,
|
||||
useProjectView,
|
||||
useUser,
|
||||
} from "@/hooks/store";
|
||||
|
||||
const ProjectViewIssuesHeader: React.FC = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, projectId, viewId } = useParams();
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { issueFilters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.PROJECT_VIEW);
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { toggleCreateIssueModal } = useCommandPalette();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { projectViewIds, getViewById } = useProjectView();
|
||||
const { projectStates } = useProjectState();
|
||||
const { projectLabels } = useLabel();
|
||||
const {
|
||||
project: { projectMemberIds },
|
||||
} = useMember();
|
||||
|
||||
const activeLayout = issueFilters?.displayFilters?.layout;
|
||||
|
||||
const handleLayoutChange = useCallback(
|
||||
(layout: TIssueLayouts) => {
|
||||
if (!workspaceSlug || !projectId || !viewId) return;
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.DISPLAY_FILTERS,
|
||||
{ layout: layout },
|
||||
viewId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, projectId, viewId, updateFilters]
|
||||
);
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId || !viewId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.FILTERS,
|
||||
{ [key]: newValues },
|
||||
viewId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, projectId, viewId, issueFilters, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !projectId || !viewId) return;
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.DISPLAY_FILTERS,
|
||||
updatedDisplayFilter,
|
||||
viewId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, projectId, viewId, updateFilters]
|
||||
);
|
||||
|
||||
const handleDisplayProperties = useCallback(
|
||||
(property: Partial<IIssueDisplayProperties>) => {
|
||||
if (!workspaceSlug || !projectId || !viewId) return;
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
EIssueFilterType.DISPLAY_PROPERTIES,
|
||||
property,
|
||||
viewId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, projectId, viewId, updateFilters]
|
||||
);
|
||||
|
||||
const viewDetails = viewId ? getViewById(viewId.toString()) : null;
|
||||
|
||||
const canUserCreateIssue =
|
||||
currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole);
|
||||
|
||||
const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0;
|
||||
|
||||
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">
|
||||
<Breadcrumbs>
|
||||
<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
|
||||
href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/views`}
|
||||
label="Views"
|
||||
icon={<PhotoFilterIcon className="h-4 w-4 text-custom-text-300" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="component"
|
||||
component={
|
||||
<CustomMenu
|
||||
label={
|
||||
<>
|
||||
{viewDetails?.logo_props?.in_use ? (
|
||||
<Logo logo={viewDetails.logo_props} size={12} type="lucide" />
|
||||
) : (
|
||||
<PhotoFilterIcon height={12} width={12} />
|
||||
)}
|
||||
{viewDetails?.name && truncateText(viewDetails.name, 40)}
|
||||
</>
|
||||
}
|
||||
className="ml-1.5"
|
||||
placement="bottom-start"
|
||||
>
|
||||
{projectViewIds?.map((viewId) => {
|
||||
const view = getViewById(viewId);
|
||||
|
||||
if (!view) return;
|
||||
|
||||
return (
|
||||
<CustomMenu.MenuItem key={viewId}>
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/views/${viewId}`}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{view?.logo_props?.in_use ? (
|
||||
<Logo logo={view.logo_props} size={12} type="lucide" />
|
||||
) : (
|
||||
<PhotoFilterIcon height={12} width={12} />
|
||||
)}
|
||||
{truncateText(view.name, 40)}
|
||||
</Link>
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
})}
|
||||
</CustomMenu>
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutSelection
|
||||
layouts={["list", "kanban", "calendar", "spreadsheet", "gantt_chart"]}
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
|
||||
<FiltersDropdown
|
||||
title="Filters"
|
||||
placement="bottom-end"
|
||||
disabled={!canUserCreateIssue}
|
||||
isFiltersApplied={isFiltersApplied}
|
||||
>
|
||||
<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
|
||||
onClick={() => {
|
||||
setTrackElement("PROJECT_VIEW_PAGE_HEADER");
|
||||
toggleCreateIssueModal(true, EIssuesStoreType.PROJECT_VIEW);
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
Add Issue
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectViewIssuesHeader;
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
|
||||
import ProjectViewIssuesHeader from "./header";
|
||||
|
||||
const ProjectViewIssuesHeaderPage = () => <AppHeaderWrapper header={<ProjectViewIssuesHeader />} />;
|
||||
|
||||
export default ProjectViewIssuesHeaderPage;
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// ui
|
||||
import { Breadcrumbs, PhotoFilterIcon, Button } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink, Logo } from "@/components/common";
|
||||
import { ViewListHeader } from "@/components/views";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// hooks
|
||||
import { useCommandPalette, useProject, useUser } from "@/hooks/store";
|
||||
|
||||
const ProjectViewsHeader: React.FC = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { toggleCreateViewModal } = useCommandPalette();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { currentProjectDetails } = useProject();
|
||||
|
||||
const canUserCreateIssue =
|
||||
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>
|
||||
<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={<PhotoFilterIcon className="h-4 w-4 text-custom-text-300" />} />
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</div>
|
||||
{canUserCreateIssue && (
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<ViewListHeader />
|
||||
<div>
|
||||
<Button variant="primary" size="sm" onClick={() => toggleCreateViewModal(true)}>
|
||||
Add View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectViewsHeader;
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
|
||||
import ProjectViewsHeader from "./header";
|
||||
|
||||
const ProjectViewsHeaderPage = () => <AppHeaderWrapper header={<ProjectViewsHeader />} />;
|
||||
|
||||
export default ProjectViewsHeaderPage;
|
||||
185
web/app/[workspaceSlug]/@header/projects/header.tsx
Normal file
185
web/app/[workspaceSlug]/@header/projects/header.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { useCallback, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Search, Briefcase, X, ListFilter } from "lucide-react";
|
||||
// types
|
||||
import { TProjectFilters } from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, Button } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
import { FiltersDropdown } from "@/components/issues";
|
||||
import { ProjectFiltersSelection, ProjectOrderByDropdown } from "@/components/project";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "@/constants/workspace";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { calculateTotalFilters } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import { useAppRouter, useCommandPalette, useEventTracker, useMember, useProjectFilter, useUser } from "@/hooks/store";
|
||||
import useOutsideClickDetector from "@/hooks/use-outside-click-detector";
|
||||
|
||||
const ProjectsHeader = observer(() => {
|
||||
// states
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
// refs
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// store hooks
|
||||
const { toggleCreateProjectModal } = useCommandPalette();
|
||||
const { workspaceSlug } = useAppRouter();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const {
|
||||
membership: { currentWorkspaceRole },
|
||||
} = useUser();
|
||||
const {
|
||||
currentWorkspaceDisplayFilters: displayFilters,
|
||||
currentWorkspaceFilters: filters,
|
||||
updateFilters,
|
||||
updateDisplayFilters,
|
||||
searchQuery,
|
||||
updateSearchQuery,
|
||||
} = useProjectFilter();
|
||||
const {
|
||||
workspace: { workspaceMemberIds },
|
||||
} = useMember();
|
||||
// outside click detector hook
|
||||
useOutsideClickDetector(inputRef, () => {
|
||||
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
|
||||
});
|
||||
// auth
|
||||
const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
|
||||
|
||||
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, { [key]: newValues });
|
||||
},
|
||||
[filters, updateFilters, workspaceSlug]
|
||||
);
|
||||
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Escape") {
|
||||
if (searchQuery && searchQuery.trim() !== "") updateSearchQuery("");
|
||||
else setIsSearchOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
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" />} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex items-center justify-end gap-3">
|
||||
<div className="flex items-center">
|
||||
{!isSearchOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="-mr-1 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
|
||||
onClick={() => {
|
||||
setIsSearchOpen(true);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
|
||||
{
|
||||
"w-30 md:w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 focus:outline-none"
|
||||
placeholder="Search"
|
||||
value={searchQuery}
|
||||
onChange={(e) => updateSearchQuery(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
{isSearchOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center"
|
||||
onClick={() => {
|
||||
updateSearchQuery("");
|
||||
setIsSearchOpen(false);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</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, {
|
||||
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, val);
|
||||
}}
|
||||
memberIds={workspaceMemberIds ?? undefined}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
{isAuthorizedUser && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTrackElement("Projects page");
|
||||
toggleCreateProjectModal(true);
|
||||
}}
|
||||
className="items-center gap-1"
|
||||
>
|
||||
<span className="hidden sm:inline-block">Add</span> Project
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectsHeader;
|
||||
91
web/app/[workspaceSlug]/@header/projects/mobile-header.tsx
Normal file
91
web/app/[workspaceSlug]/@header/projects/mobile-header.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// icons
|
||||
import { ChevronDown, ListFilter } from "lucide-react";
|
||||
// types
|
||||
import { TProjectFilters } from "@plane/types";
|
||||
// hooks
|
||||
import { FiltersDropdown } from "@/components/issues/issue-layouts";
|
||||
import { ProjectFiltersSelection, ProjectOrderByDropdown } from "@/components/project/dropdowns";
|
||||
// helpers
|
||||
import { calculateTotalFilters } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import { useAppRouter, useMember, useProjectFilter } from "@/hooks/store";
|
||||
|
||||
const ProjectsMobileHeader = observer(() => {
|
||||
const {
|
||||
currentWorkspaceDisplayFilters: displayFilters,
|
||||
currentWorkspaceFilters: filters,
|
||||
updateDisplayFilters,
|
||||
updateFilters,
|
||||
} = useProjectFilter();
|
||||
|
||||
const { workspaceSlug } = useAppRouter();
|
||||
|
||||
const {
|
||||
workspace: { workspaceMemberIds },
|
||||
} = useMember();
|
||||
|
||||
const handleFilters = useCallback(
|
||||
(key: keyof TProjectFilters, value: string | string[]) => {
|
||||
if (!workspaceSlug) return;
|
||||
const newValues = filters?.[key] ?? [];
|
||||
if (Array.isArray(value))
|
||||
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 newValues.push(value);
|
||||
}
|
||||
updateFilters(workspaceSlug, { [key]: newValues });
|
||||
},
|
||||
[filters, updateFilters, workspaceSlug]
|
||||
);
|
||||
|
||||
const isFiltersApplied = calculateTotalFilters(filters ?? {}) !== 0;
|
||||
|
||||
return (
|
||||
<div className="flex py-2 border-b border-custom-border-200 md:hidden bg-custom-background-100 w-full">
|
||||
<ProjectOrderByDropdown
|
||||
value={displayFilters?.order_by}
|
||||
onChange={(val) => {
|
||||
if (!workspaceSlug || val === displayFilters?.order_by) return;
|
||||
updateDisplayFilters(workspaceSlug, {
|
||||
order_by: val,
|
||||
});
|
||||
}}
|
||||
isMobile
|
||||
/>
|
||||
<div className="border-l border-custom-border-200 flex justify-around w-full">
|
||||
<FiltersDropdown
|
||||
icon={<ListFilter className="h-3 w-3" />}
|
||||
title="Filters"
|
||||
placement="bottom-end"
|
||||
menuButton={
|
||||
<div className="flex text-sm items-center gap-2 neutral-primary text-custom-text-200">
|
||||
<ListFilter className="h-3 w-3" />
|
||||
Filters
|
||||
<ChevronDown className="h-3 w-3" strokeWidth={2} />
|
||||
</div>
|
||||
}
|
||||
isFiltersApplied={isFiltersApplied}
|
||||
>
|
||||
<ProjectFiltersSelection
|
||||
displayFilters={displayFilters ?? {}}
|
||||
filters={filters ?? {}}
|
||||
handleFiltersUpdate={handleFilters}
|
||||
handleDisplayFiltersUpdate={(val) => {
|
||||
if (!workspaceSlug) return;
|
||||
updateDisplayFilters(workspaceSlug, val);
|
||||
}}
|
||||
memberIds={workspaceMemberIds ?? undefined}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectsMobileHeader;
|
||||
13
web/app/[workspaceSlug]/@header/projects/page.tsx
Normal file
13
web/app/[workspaceSlug]/@header/projects/page.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper";
|
||||
import ProjectsHeader from "./header";
|
||||
import ProjectsMobileHeader from "./mobile-header";
|
||||
|
||||
|
||||
const ProjectsHeaderPage = () => (
|
||||
<AppHeaderWrapper header={<ProjectsHeader />} mobileHeader={<ProjectsMobileHeader />} />
|
||||
);
|
||||
|
||||
export default ProjectsHeaderPage;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import DefaultWorkspaceSettingsHeader from "../page";
|
||||
export default DefaultWorkspaceSettingsHeader;
|
||||
39
web/app/[workspaceSlug]/@header/settings/header.tsx
Normal file
39
web/app/[workspaceSlug]/@header/settings/header.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";;
|
||||
import { Settings } from "lucide-react";
|
||||
// ui
|
||||
import { Breadcrumbs } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
|
||||
const WorkspaceSettingHeader: FC = observer(() => {
|
||||
const { currentWorkspace } = 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>
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
export default WorkspaceSettingHeader;
|
||||
9
web/app/[workspaceSlug]/@header/settings/page.tsx
Normal file
9
web/app/[workspaceSlug]/@header/settings/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import AppHeaderWrapper from "../../app-header-wrapper";
|
||||
import WorkspaceSettingHeader from "./header";
|
||||
|
||||
const WorkspaceSettingHeaderPage = () => <AppHeaderWrapper header={<WorkspaceSettingHeader />} />;
|
||||
|
||||
export default WorkspaceSettingHeaderPage;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import DefaultWorkspaceViewsHeader from "../page";
|
||||
export default DefaultWorkspaceViewsHeader;
|
||||
148
web/app/[workspaceSlug]/@header/workspace-views/header.tsx
Normal file
148
web/app/[workspaceSlug]/@header/workspace-views/header.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useParams } from "next/navigation";
|
||||
// types
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, LayersIcon } from "@plane/ui";
|
||||
// components
|
||||
import { BreadcrumbLink } from "@/components/common";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection } from "@/components/issues";
|
||||
import { CreateUpdateWorkspaceViewModal } from "@/components/workspace";
|
||||
// constants
|
||||
import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
|
||||
import { EUserWorkspaceRoles } from "@/constants/workspace";
|
||||
// helpers
|
||||
import { calculateTotalFilters } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import { useLabel, useMember, useUser, useIssues } from "@/hooks/store";
|
||||
|
||||
const GlobalIssuesHeader: React.FC = observer(() => {
|
||||
// states
|
||||
const [createViewModal, setCreateViewModal] = useState(false);
|
||||
// router
|
||||
const { workspaceSlug, globalViewId } = useParams();
|
||||
// store hooks
|
||||
const {
|
||||
issuesFilter: { filters, updateFilters },
|
||||
} = useIssues(EIssuesStoreType.GLOBAL);
|
||||
const {
|
||||
membership: { currentWorkspaceRole },
|
||||
} = useUser();
|
||||
const { workspaceLabels } = useLabel();
|
||||
const {
|
||||
workspace: { workspaceMemberIds },
|
||||
} = useMember();
|
||||
|
||||
const issueFilters = globalViewId ? filters[globalViewId.toString()] : undefined;
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !globalViewId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
undefined,
|
||||
EIssueFilterType.FILTERS,
|
||||
{ [key]: newValues },
|
||||
globalViewId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, issueFilters, updateFilters, globalViewId]
|
||||
);
|
||||
|
||||
const handleDisplayFilters = useCallback(
|
||||
(updatedDisplayFilter: Partial<IIssueDisplayFilterOptions>) => {
|
||||
if (!workspaceSlug || !globalViewId) return;
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
undefined,
|
||||
EIssueFilterType.DISPLAY_FILTERS,
|
||||
updatedDisplayFilter,
|
||||
globalViewId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, updateFilters, globalViewId]
|
||||
);
|
||||
|
||||
const handleDisplayProperties = useCallback(
|
||||
(property: Partial<IIssueDisplayProperties>) => {
|
||||
if (!workspaceSlug || !globalViewId) return;
|
||||
updateFilters(
|
||||
workspaceSlug.toString(),
|
||||
undefined,
|
||||
EIssueFilterType.DISPLAY_PROPERTIES,
|
||||
property,
|
||||
globalViewId.toString()
|
||||
);
|
||||
},
|
||||
[workspaceSlug, updateFilters, globalViewId]
|
||||
);
|
||||
|
||||
const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
|
||||
|
||||
const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0;
|
||||
|
||||
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">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<BreadcrumbLink label={`All Issues`} icon={<LayersIcon className="h-4 w-4 text-custom-text-300" />} />
|
||||
}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<>
|
||||
<FiltersDropdown title="Filters" placement="bottom-end" isFiltersApplied={isFiltersApplied}>
|
||||
<FilterSelection
|
||||
layoutDisplayFiltersOptions={ISSUE_DISPLAY_FILTERS_BY_LAYOUT.my_issues.spreadsheet}
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
labels={workspaceLabels ?? undefined}
|
||||
memberIds={workspaceMemberIds ?? undefined}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
<FiltersDropdown title="Display" placement="bottom-end">
|
||||
<DisplayFiltersSelection
|
||||
layoutDisplayFiltersOptions={ISSUE_DISPLAY_FILTERS_BY_LAYOUT.my_issues.spreadsheet}
|
||||
displayFilters={issueFilters?.displayFilters ?? {}}
|
||||
handleDisplayFiltersUpdate={handleDisplayFilters}
|
||||
displayProperties={issueFilters?.displayProperties ?? {}}
|
||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</>
|
||||
{isAuthorizedUser && (
|
||||
<Button variant="primary" size="sm" onClick={() => setCreateViewModal(true)}>
|
||||
Add View
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default GlobalIssuesHeader;
|
||||
9
web/app/[workspaceSlug]/@header/workspace-views/page.tsx
Normal file
9
web/app/[workspaceSlug]/@header/workspace-views/page.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"use client";
|
||||
|
||||
// components
|
||||
import AppHeaderWrapper from "../../app-header-wrapper";
|
||||
import GlobalIssuesHeader from "./header";
|
||||
|
||||
const GlobalIssuesHeaderPage = () => <AppHeaderWrapper header={<GlobalIssuesHeader />} />;
|
||||
|
||||
export default GlobalIssuesHeaderPage;
|
||||
23
web/app/[workspaceSlug]/active-cycles/page.tsx
Normal file
23
web/app/[workspaceSlug]/active-cycles/page.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { WorkspaceActiveCyclesUpgrade } from "@/components/workspace";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
|
||||
const WorkspaceActiveCyclesPage = observer(() => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
// derived values
|
||||
const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Active Cycles` : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<WorkspaceActiveCyclesUpgrade />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default WorkspaceActiveCyclesPage;
|
||||
74
web/app/[workspaceSlug]/analytics/page.tsx
Normal file
74
web/app/[workspaceSlug]/analytics/page.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"use client";
|
||||
|
||||
import React, { Fragment } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Tab } from "@headlessui/react";
|
||||
// components
|
||||
import { CustomAnalytics, ScopeAndDemand } from "@/components/analytics";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
// constants
|
||||
import { ANALYTICS_TABS } from "@/constants/analytics";
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
// hooks
|
||||
import { useCommandPalette, useEventTracker, useProject, useWorkspace } from "@/hooks/store";;
|
||||
|
||||
const AnalyticsPage = observer(() => {
|
||||
const searchParams = useSearchParams();
|
||||
const analytics_tab = searchParams.get("analytics_tab");
|
||||
// store hooks
|
||||
const { toggleCreateProjectModal } = useCommandPalette();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { workspaceProjectIds } = useProject();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
// derived values
|
||||
const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Analytics` : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
{workspaceProjectIds && workspaceProjectIds.length > 0 ? (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-custom-background-100">
|
||||
<Tab.Group as={Fragment} defaultIndex={analytics_tab === "custom" ? 1 : 0}>
|
||||
<Tab.List as="div" className="flex space-x-2 border-b h-[50px] border-custom-border-200 px-0 md:px-5">
|
||||
{ANALYTICS_TABS.map((tab) => (
|
||||
<Tab key={tab.key} as={Fragment}>
|
||||
{({ selected }) => (
|
||||
<button
|
||||
className={`text-sm group relative flex items-center gap-1 h-[50px] px-3 cursor-pointer transition-all font-medium outline-none ${selected ? "text-custom-primary-100 " : "hover:text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
{tab.title}
|
||||
<div
|
||||
className={`border absolute bottom-0 right-0 left-0 rounded-t-md ${selected ? "border-custom-primary-100" : "border-transparent group-hover:border-custom-border-200"}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
<Tab.Panels as={Fragment}>
|
||||
<Tab.Panel as={Fragment}>
|
||||
<ScopeAndDemand fullScreen />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel as={Fragment}>
|
||||
<CustomAnalytics fullScreen />
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
type={EmptyStateType.WORKSPACE_ANALYTICS}
|
||||
primaryButtonOnClick={() => {
|
||||
setTrackElement("Analytics empty state");
|
||||
toggleCreateProjectModal(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default AnalyticsPage;
|
||||
26
web/app/[workspaceSlug]/app-header-wrapper.tsx
Normal file
26
web/app/[workspaceSlug]/app-header-wrapper.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
// components
|
||||
import { SidebarHamburgerToggle } from "@/components/core";
|
||||
|
||||
export interface IAppHeaderWrapper {
|
||||
header: ReactNode;
|
||||
mobileHeader?: ReactNode;
|
||||
}
|
||||
|
||||
const AppHeaderWrapper = ({ header, mobileHeader }: IAppHeaderWrapper) => (
|
||||
<>
|
||||
<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>
|
||||
{mobileHeader && mobileHeader}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export default AppHeaderWrapper;
|
||||
25
web/app/[workspaceSlug]/layout.tsx
Normal file
25
web/app/[workspaceSlug]/layout.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"use client";
|
||||
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { WorkspaceAuthWrapper } from "@/layouts/auth-layout";
|
||||
import { AuthenticationWrapper } from "@/lib/wrappers";
|
||||
import AppSidebar from "./sidebar";
|
||||
|
||||
export default function WorkspaceLayout({ header, children }: { header: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthenticationWrapper>
|
||||
<CommandPalette />
|
||||
<WorkspaceAuthWrapper>
|
||||
<div className="relative flex h-screen w-full overflow-hidden">
|
||||
<AppSidebar />
|
||||
<main className="relative flex h-full w-full flex-col overflow-hidden bg-custom-background-100">
|
||||
{header}
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<div className="relative h-full w-full overflow-x-hidden overflow-y-scroll">{children}</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</WorkspaceAuthWrapper>
|
||||
</AuthenticationWrapper>
|
||||
);
|
||||
}
|
||||
23
web/app/[workspaceSlug]/page.tsx
Normal file
23
web/app/[workspaceSlug]/page.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { WorkspaceDashboardView } from "@/components/page-views";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
|
||||
const WorkspacePage = observer(() => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
// derived values
|
||||
const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Home` : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<WorkspaceDashboardView />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default WorkspacePage;
|
||||
72
web/app/[workspaceSlug]/profile/[userId]/activity/page.tsx
Normal file
72
web/app/[workspaceSlug]/profile/[userId]/activity/page.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// ui
|
||||
import { Button } from "@plane/ui";
|
||||
// components
|
||||
import { DownloadActivityButton, WorkspaceActivityListPage } from "@/components/profile";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "@/constants/workspace";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store";
|
||||
|
||||
const PER_PAGE = 100;
|
||||
|
||||
const ProfileActivityPage = observer(() => {
|
||||
// states
|
||||
const [pageCount, setPageCount] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [resultsCount, setResultsCount] = useState(0);
|
||||
// router
|
||||
|
||||
const { userId } = useParams();
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const {
|
||||
membership: { currentWorkspaceRole },
|
||||
} = useUser();
|
||||
|
||||
const updateTotalPages = (count: number) => setTotalPages(count);
|
||||
|
||||
const updateResultsCount = (count: number) => setResultsCount(count);
|
||||
|
||||
const handleLoadMore = () => setPageCount((prev) => prev + 1);
|
||||
|
||||
const activityPages: JSX.Element[] = [];
|
||||
for (let i = 0; i < pageCount; i++)
|
||||
activityPages.push(
|
||||
<WorkspaceActivityListPage
|
||||
key={i}
|
||||
cursor={`${PER_PAGE}:${i}:0`}
|
||||
perPage={PER_PAGE}
|
||||
updateResultsCount={updateResultsCount}
|
||||
updateTotalPages={updateTotalPages}
|
||||
/>
|
||||
);
|
||||
|
||||
const canDownloadActivity =
|
||||
currentUser?.id === userId && !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden py-5">
|
||||
<div className="flex items-center justify-between gap-2 px-5 md:px-9">
|
||||
<h3 className="text-lg font-medium">Recent activity</h3>
|
||||
{canDownloadActivity && <DownloadActivityButton />}
|
||||
</div>
|
||||
<div className="vertical-scrollbar scrollbar-md flex h-full flex-col overflow-y-auto px-5 md:px-9">
|
||||
{activityPages}
|
||||
{pageCount < totalPages && resultsCount !== 0 && (
|
||||
<div className="flex w-full items-center justify-center text-xs">
|
||||
<Button variant="accent-primary" size="sm" onClick={handleLoadMore}>
|
||||
Load more
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProfileActivityPage;
|
||||
15
web/app/[workspaceSlug]/profile/[userId]/assigned/page.tsx
Normal file
15
web/app/[workspaceSlug]/profile/[userId]/assigned/page.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProfileIssuesPage } from "@/components/profile/profile-issues";
|
||||
|
||||
const ProfileAssignedIssuesPage = () => (
|
||||
<>
|
||||
<PageHead title="Profile - Assigned" />
|
||||
<ProfileIssuesPage type="assigned" />
|
||||
</>
|
||||
);
|
||||
|
||||
export default ProfileAssignedIssuesPage;
|
||||
16
web/app/[workspaceSlug]/profile/[userId]/created/page.tsx
Normal file
16
web/app/[workspaceSlug]/profile/[userId]/created/page.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"use client";
|
||||
|
||||
// store
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProfileIssuesPage } from "@/components/profile/profile-issues";
|
||||
|
||||
const ProfileCreatedIssuesPage = () => (
|
||||
<>
|
||||
<PageHead title="Profile - Created" />
|
||||
<ProfileIssuesPage type="created" />
|
||||
</>
|
||||
);
|
||||
|
||||
export default observer(ProfileCreatedIssuesPage);
|
||||
51
web/app/[workspaceSlug]/profile/[userId]/layout.tsx
Normal file
51
web/app/[workspaceSlug]/profile/[userId]/layout.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
// components
|
||||
import { ProfileSidebar } from "@/components/profile";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "@/constants/workspace";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store";
|
||||
// local components
|
||||
import { ProfileNavbar } from "./navbar";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const AUTHORIZED_ROLES = [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.VIEWER];
|
||||
|
||||
const ProfileAuthWrapper: React.FC<Props> = observer((props) => {
|
||||
const { children } = props;
|
||||
// router
|
||||
const pathname = usePathname();
|
||||
// store hooks
|
||||
const {
|
||||
membership: { currentWorkspaceRole },
|
||||
} = useUser();
|
||||
// derived values
|
||||
const isAuthorized = currentWorkspaceRole && AUTHORIZED_ROLES.includes(currentWorkspaceRole);
|
||||
const isAuthorizedPath =
|
||||
pathname.includes("assigned") || pathname.includes("created") || pathname.includes("subscribed");
|
||||
const isIssuesTab = pathname.includes("assigned") || pathname.includes("created") || pathname.includes("subscribed");
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex md:overflow-hidden">
|
||||
<div className="flex w-full flex-col md:h-full md:overflow-hidden">
|
||||
<ProfileNavbar isAuthorized={!!isAuthorized} showProfileIssuesFilter={isIssuesTab} />
|
||||
{isAuthorized || !isAuthorizedPath ? (
|
||||
<div className={`w-full overflow-hidden md:h-full`}>{children}</div>
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center text-custom-text-200">
|
||||
You do not have the permission to access this page.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ProfileSidebar />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProfileAuthWrapper;
|
||||
44
web/app/[workspaceSlug]/profile/[userId]/navbar.tsx
Normal file
44
web/app/[workspaceSlug]/profile/[userId]/navbar.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
|
||||
// components
|
||||
import { ProfileIssuesFilter } from "@/components/profile";
|
||||
// constants
|
||||
import { PROFILE_ADMINS_TAB, PROFILE_VIEWER_TAB } from "@/constants/profile";
|
||||
|
||||
type Props = {
|
||||
isAuthorized: boolean;
|
||||
showProfileIssuesFilter?: boolean;
|
||||
};
|
||||
|
||||
export const ProfileNavbar: React.FC<Props> = (props) => {
|
||||
const { isAuthorized, showProfileIssuesFilter } = props;
|
||||
|
||||
const { workspaceSlug, userId } = useParams();
|
||||
const pathname = usePathname();
|
||||
|
||||
const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB;
|
||||
|
||||
return (
|
||||
<div className="sticky -top-0.5 z-10 hidden md:flex items-center justify-between gap-4 border-b border-custom-border-300 bg-custom-background-100 px-4 sm:px-5 md:static">
|
||||
<div className="flex items-center overflow-x-scroll">
|
||||
{tabsList.map((tab) => (
|
||||
<Link key={tab.route} href={`/${workspaceSlug}/profile/${userId}/${tab.route}`}>
|
||||
<span
|
||||
className={`flex whitespace-nowrap border-b-2 p-4 text-sm font-medium outline-none ${
|
||||
pathname === `/${workspaceSlug}/profile/${userId}${tab.selected}`
|
||||
? "border-custom-primary-100 text-custom-primary-100"
|
||||
: "border-transparent"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{showProfileIssuesFilter && <ProfileIssuesFilter />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
56
web/app/[workspaceSlug]/profile/[userId]/page.tsx
Normal file
56
web/app/[workspaceSlug]/profile/[userId]/page.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// types
|
||||
import { IUserStateDistribution, TStateGroups } from "@plane/types";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import {
|
||||
ProfileActivity,
|
||||
ProfilePriorityDistribution,
|
||||
ProfileStateDistribution,
|
||||
ProfileStats,
|
||||
ProfileWorkload,
|
||||
} from "@/components/profile";
|
||||
// constants
|
||||
import { USER_PROFILE_DATA } from "@/constants/fetch-keys";
|
||||
import { GROUP_CHOICES } from "@/constants/project";
|
||||
// services
|
||||
import { UserService } from "@/services/user.service";
|
||||
|
||||
// services
|
||||
const userService = new UserService();
|
||||
|
||||
const ProfileOverviewPage = () => {
|
||||
const { workspaceSlug, userId } = useParams();
|
||||
|
||||
const { data: userProfile } = useSWR(
|
||||
workspaceSlug && userId ? USER_PROFILE_DATA(workspaceSlug.toString(), userId.toString()) : null,
|
||||
workspaceSlug && userId ? () => userService.getUserProfileData(workspaceSlug.toString(), userId.toString()) : null
|
||||
);
|
||||
|
||||
const stateDistribution: IUserStateDistribution[] = Object.keys(GROUP_CHOICES).map((key) => {
|
||||
const group = userProfile?.state_distribution.find((g) => g.state_group === key);
|
||||
|
||||
if (group) return group;
|
||||
else return { state_group: key as TStateGroups, state_count: 0 };
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Profile - Summary" />
|
||||
<div className="h-full w-full space-y-7 overflow-y-auto px-5 py-5 md:px-9 vertical-scrollbar scrollbar-md">
|
||||
<ProfileStats userProfile={userProfile} />
|
||||
<ProfileWorkload stateDistribution={stateDistribution} />
|
||||
<div className="grid grid-cols-1 items-stretch gap-5 xl:grid-cols-2">
|
||||
<ProfilePriorityDistribution userProfile={userProfile} />
|
||||
<ProfileStateDistribution stateDistribution={stateDistribution} userProfile={userProfile} />
|
||||
</div>
|
||||
<ProfileActivity />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileOverviewPage;
|
||||
16
web/app/[workspaceSlug]/profile/[userId]/subscribed/page.tsx
Normal file
16
web/app/[workspaceSlug]/profile/[userId]/subscribed/page.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"use client";
|
||||
|
||||
// store
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProfileIssuesPage } from "@/components/profile/profile-issues";
|
||||
|
||||
const ProfileSubscribedIssuesPage = () => (
|
||||
<>
|
||||
<PageHead title="Profile - Subscribed" />
|
||||
<ProfileIssuesPage type="subscribed" />
|
||||
</>
|
||||
);
|
||||
|
||||
export default observer(ProfileSubscribedIssuesPage);
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ArchivedCycleLayoutRoot, ArchivedCyclesHeader } from "@/components/cycles";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
|
||||
const ProjectArchivedCyclesPage = observer(() => {
|
||||
// router
|
||||
const { projectId } = useParams();
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const project = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
const pageTitle = project?.name && `${project?.name} - Archived cycles`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
<ArchivedCyclesHeader />
|
||||
<ArchivedCycleLayoutRoot />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectArchivedCyclesPage;
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// icons
|
||||
import { ArchiveRestoreIcon } from "lucide-react";
|
||||
// ui
|
||||
import { ArchiveIcon, Button, Loader, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { IssueDetailRoot } from "@/components/issues";
|
||||
// constants
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// hooks
|
||||
import { useIssueDetail, useIssues, useProject, useUser } from "@/hooks/store";
|
||||
|
||||
const ArchivedIssueDetailsPage = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, archivedIssueId } = useParams();
|
||||
// states
|
||||
const [isRestoring, setIsRestoring] = useState(false);
|
||||
// hooks
|
||||
const {
|
||||
fetchIssue,
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const {
|
||||
issues: { restoreIssue },
|
||||
} = useIssues(EIssuesStoreType.ARCHIVED);
|
||||
const { getProjectById } = useProject();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
|
||||
const { isLoading, data: swrArchivedIssueDetails } = useSWR(
|
||||
workspaceSlug && projectId && archivedIssueId
|
||||
? `ARCHIVED_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${archivedIssueId}`
|
||||
: null,
|
||||
workspaceSlug && projectId && archivedIssueId
|
||||
? () => fetchIssue(workspaceSlug.toString(), projectId.toString(), archivedIssueId.toString(), "ARCHIVED")
|
||||
: null
|
||||
);
|
||||
|
||||
// derived values
|
||||
const issue = archivedIssueId ? getIssueById(archivedIssueId.toString()) : undefined;
|
||||
const project = issue ? getProjectById(issue?.project_id) : undefined;
|
||||
const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined;
|
||||
// auth
|
||||
const canRestoreIssue = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
|
||||
|
||||
if (!issue) return <></>;
|
||||
|
||||
const handleRestore = async () => {
|
||||
if (!workspaceSlug || !projectId || !archivedIssueId) return;
|
||||
|
||||
setIsRestoring(true);
|
||||
|
||||
await restoreIssue(workspaceSlug.toString(), projectId.toString(), archivedIssueId.toString())
|
||||
.then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Restore success",
|
||||
message: "Your issue can be found in project issues.",
|
||||
});
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/issues/${archivedIssueId}`);
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Issue could not be restored. Please try again.",
|
||||
});
|
||||
})
|
||||
.finally(() => setIsRestoring(false));
|
||||
};
|
||||
|
||||
const issueLoader = !issue || isLoading ? true : false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
{issueLoader ? (
|
||||
<Loader className="flex h-full gap-5 p-5">
|
||||
<div className="basis-2/3 space-y-2">
|
||||
<Loader.Item height="30px" width="40%" />
|
||||
<Loader.Item height="15px" width="60%" />
|
||||
<Loader.Item height="15px" width="60%" />
|
||||
<Loader.Item height="15px" width="40%" />
|
||||
</div>
|
||||
<div className="basis-1/3 space-y-3">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
</div>
|
||||
</Loader>
|
||||
) : (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
<div className="h-full w-full space-y-3 divide-y-2 divide-custom-border-200 overflow-y-auto">
|
||||
{issue?.archived_at && canRestoreIssue && (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border border-custom-border-200 bg-custom-background-90 px-2.5 py-2 text-sm text-custom-text-200 my-5 mx-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ArchiveIcon className="h-4 w-4" />
|
||||
<p>This issue has been archived.</p>
|
||||
</div>
|
||||
<Button
|
||||
className="flex items-center gap-1.5 rounded-md border border-custom-border-200 p-1.5 text-sm"
|
||||
onClick={handleRestore}
|
||||
disabled={isRestoring}
|
||||
variant="neutral-primary"
|
||||
>
|
||||
<ArchiveRestoreIcon className="h-3.5 w-3.5" />
|
||||
<span>{isRestoring ? "Restoring" : "Restore"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{workspaceSlug && projectId && archivedIssueId && (
|
||||
<IssueDetailRoot
|
||||
swrIssueDetails={swrArchivedIssueDetails}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
issueId={archivedIssueId.toString()}
|
||||
is_archived
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ArchivedIssueDetailsPage;
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ArchivedIssueLayoutRoot, ArchivedIssuesHeader } from "@/components/issues";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
|
||||
const ProjectArchivedIssuesPage = observer(() => {
|
||||
// router
|
||||
const { projectId } = useParams();
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const project = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
const pageTitle = project?.name && `${project?.name} - Archived issues`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
<ArchivedIssuesHeader />
|
||||
<ArchivedIssueLayoutRoot />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectArchivedIssuesPage;
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ArchivedModuleLayoutRoot, ArchivedModulesHeader } from "@/components/modules";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
|
||||
const ProjectArchivedModulesPage = observer(() => {
|
||||
// router
|
||||
const { projectId } = useParams();
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const project = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
const pageTitle = project?.name && `${project?.name} - Archived modules`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden">
|
||||
<ArchivedModulesHeader />
|
||||
<ArchivedModuleLayoutRoot />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectArchivedModulesPage;
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { EmptyState } from "@/components/common";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { CycleDetailsSidebar } from "@/components/cycles";
|
||||
import { CycleLayoutRoot } from "@/components/issues/issue-layouts";
|
||||
// hooks
|
||||
import { useCycle, useProject } from "@/hooks/store";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
// assets
|
||||
import emptyCycle from "public/empty-state/cycle.svg";
|
||||
|
||||
const CycleDetailPage = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId } = useParams();
|
||||
// store hooks
|
||||
const { fetchCycleDetails, getCycleById } = useCycle();
|
||||
const { getProjectById } = useProject();
|
||||
// hooks
|
||||
const { setValue, storedValue } = useLocalStorage("cycle_sidebar_collapsed", "false");
|
||||
// fetching cycle details
|
||||
const { error } = useSWR(
|
||||
workspaceSlug && projectId && cycleId ? `CYCLE_DETAILS_${cycleId.toString()}` : null,
|
||||
workspaceSlug && projectId && cycleId
|
||||
? () => fetchCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString())
|
||||
: null
|
||||
);
|
||||
// derived values
|
||||
const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false;
|
||||
const cycle = cycleId ? getCycleById(cycleId.toString()) : undefined;
|
||||
const project = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
const pageTitle = project?.name && cycle?.name ? `${project?.name} - ${cycle?.name}` : undefined;
|
||||
|
||||
/**
|
||||
* Toggles the sidebar
|
||||
*/
|
||||
const toggleSidebar = () => setValue(`${!isSidebarCollapsed}`);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
{error ? (
|
||||
<EmptyState
|
||||
image={emptyCycle}
|
||||
title="Cycle does not exist"
|
||||
description="The cycle you are looking for does not exist or has been deleted."
|
||||
primaryButton={{
|
||||
text: "View other cycles",
|
||||
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/cycles`),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex h-full w-full">
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<CycleLayoutRoot />
|
||||
</div>
|
||||
{cycleId && !isSidebarCollapsed && (
|
||||
<div
|
||||
className="flex h-full w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 duration-300 vertical-scrollbar scrollbar-sm"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",
|
||||
}}
|
||||
>
|
||||
<CycleDetailsSidebar cycleId={cycleId.toString()} handleClose={toggleSidebar} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default CycleDetailPage;
|
||||
101
web/app/[workspaceSlug]/projects/[projectId]/cycles/page.tsx
Normal file
101
web/app/[workspaceSlug]/projects/[projectId]/cycles/page.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// types
|
||||
import { TCycleFilters } from "@plane/types";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { CyclesView, CycleCreateUpdateModal, CycleAppliedFiltersList } from "@/components/cycles";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { CycleModuleListLayout } from "@/components/ui";
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
// helpers
|
||||
import { calculateTotalFilters } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import { useEventTracker, useCycle, useProject, useCycleFilter } from "@/hooks/store";
|
||||
|
||||
const ProjectCyclesPage = observer(() => {
|
||||
// states
|
||||
const [createModal, setCreateModal] = useState(false);
|
||||
// store hooks
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { currentProjectCycleIds, loader } = useCycle();
|
||||
const { getProjectById, currentProjectDetails } = useProject();
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// cycle filters hook
|
||||
const { clearAllFilters, currentProjectFilters, updateFilters } = useCycleFilter();
|
||||
// derived values
|
||||
const totalCycles = currentProjectCycleIds?.length ?? 0;
|
||||
const project = projectId ? getProjectById(projectId?.toString()) : undefined;
|
||||
const pageTitle = project?.name ? `${project?.name} - Cycles` : undefined;
|
||||
|
||||
const handleRemoveFilter = (key: keyof TCycleFilters, value: string | null) => {
|
||||
if (!projectId) return;
|
||||
let newValues = currentProjectFilters?.[key] ?? [];
|
||||
|
||||
if (!value) newValues = [];
|
||||
else newValues = newValues.filter((val) => val !== value);
|
||||
|
||||
updateFilters(projectId.toString(), { [key]: newValues });
|
||||
};
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
|
||||
// No access to cycle
|
||||
if (currentProjectDetails?.cycle_view === false)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<EmptyState
|
||||
type={EmptyStateType.DISABLED_PROJECT_CYCLE}
|
||||
primaryButtonLink={`/${workspaceSlug}/projects/${projectId}/settings/features`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loader) return <CycleModuleListLayout />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="w-full h-full">
|
||||
<CycleCreateUpdateModal
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
isOpen={createModal}
|
||||
handleClose={() => setCreateModal(false)}
|
||||
/>
|
||||
{totalCycles === 0 ? (
|
||||
<div className="h-full place-items-center">
|
||||
<EmptyState
|
||||
type={EmptyStateType.PROJECT_CYCLES}
|
||||
primaryButtonOnClick={() => {
|
||||
setTrackElement("Cycle empty state");
|
||||
setCreateModal(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{calculateTotalFilters(currentProjectFilters ?? {}) !== 0 && (
|
||||
<div className="border-b border-custom-border-200 px-5 py-3">
|
||||
<CycleAppliedFiltersList
|
||||
appliedFilters={currentProjectFilters ?? {}}
|
||||
handleClearAllFilters={() => clearAllFilters(projectId.toString())}
|
||||
handleRemoveFilter={handleRemoveFilter}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CyclesView workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectCyclesPage;
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { X, PenSquare } from "lucide-react";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { DraftIssueLayoutRoot } from "@/components/issues/issue-layouts/roots/draft-issue-layout-root";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
|
||||
const ProjectDraftIssuesPage = observer(() => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const project = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
const pageTitle = project?.name ? `${project?.name} - Draft Issues` : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="gap-1 flex items-center border-b border-custom-border-200 px-4 py-2.5 shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/${workspaceSlug}/projects/${projectId}/issues/`)}
|
||||
className="flex items-center gap-1.5 rounded-full border border-custom-border-200 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<PenSquare className="h-4 w-4" />
|
||||
<span>Draft Issues</span>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<DraftIssueLayoutRoot />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectDraftIssuesPage;
|
||||
63
web/app/[workspaceSlug]/projects/[projectId]/inbox/page.tsx
Normal file
63
web/app/[workspaceSlug]/projects/[projectId]/inbox/page.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { InboxIssueRoot } from "@/components/inbox";
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
// helpers
|
||||
import { EInboxIssueCurrentTab } from "@/helpers/inbox.helper";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
|
||||
const ProjectInboxPage = observer(() => {
|
||||
/// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const navigationTab = searchParams.get("currentTab");
|
||||
const inboxIssueId = searchParams.get("inboxIssueId");
|
||||
// hooks
|
||||
const { currentProjectDetails } = useProject();
|
||||
|
||||
// No access to inbox
|
||||
if (currentProjectDetails?.inbox_view === false)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<EmptyState
|
||||
type={EmptyStateType.DISABLED_PROJECT_INBOX}
|
||||
primaryButtonLink={`/${workspaceSlug}/projects/${projectId}/settings/features`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
// derived values
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Inbox` : "Plane - Inbox";
|
||||
|
||||
const currentNavigationTab = navigationTab
|
||||
? navigationTab === "open"
|
||||
? EInboxIssueCurrentTab.OPEN
|
||||
: EInboxIssueCurrentTab.CLOSED
|
||||
: undefined;
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="w-full h-full overflow-hidden">
|
||||
<InboxIssueRoot
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
inboxIssueId={inboxIssueId?.toString() || undefined}
|
||||
inboxAccessible={currentProjectDetails?.inbox_view || false}
|
||||
navigationTab={currentNavigationTab}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectInboxPage;
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import useSWR from "swr";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { EmptyState } from "@/components/common";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { IssueDetailRoot } from "@/components/issues";
|
||||
// hooks
|
||||
import { useAppTheme, useIssueDetail, useProject } from "@/hooks/store";
|
||||
// assets
|
||||
import emptyIssueDark from "@/public/empty-state/search/issues-dark.webp";
|
||||
import emptyIssueLight from "@/public/empty-state/search/issues-light.webp";
|
||||
|
||||
const IssueDetailsPage = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, issueId } = useParams();
|
||||
// hooks
|
||||
const { resolvedTheme } = useTheme();
|
||||
// store hooks
|
||||
const {
|
||||
fetchIssue,
|
||||
issue: { getIssueById },
|
||||
} = useIssueDetail();
|
||||
const { getProjectById } = useProject();
|
||||
const { toggleIssueDetailSidebar, issueDetailSidebarCollapsed } = useAppTheme();
|
||||
// fetching issue details
|
||||
const {
|
||||
isLoading,
|
||||
data: swrIssueDetails,
|
||||
error,
|
||||
} = useSWR(
|
||||
workspaceSlug && projectId && issueId ? `ISSUE_DETAIL_${workspaceSlug}_${projectId}_${issueId}` : null,
|
||||
workspaceSlug && projectId && issueId
|
||||
? () => fetchIssue(workspaceSlug.toString(), projectId.toString(), issueId.toString())
|
||||
: null
|
||||
);
|
||||
// derived values
|
||||
const issue = getIssueById(issueId?.toString() || "") || undefined;
|
||||
const project = (issue?.project_id && getProjectById(issue?.project_id)) || undefined;
|
||||
const issueLoader = !issue || isLoading ? true : false;
|
||||
const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
const handleToggleIssueDetailSidebar = () => {
|
||||
if (window && window.innerWidth < 768) {
|
||||
toggleIssueDetailSidebar(true);
|
||||
}
|
||||
if (window && issueDetailSidebarCollapsed && window.innerWidth >= 768) {
|
||||
toggleIssueDetailSidebar(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("resize", handleToggleIssueDetailSidebar);
|
||||
handleToggleIssueDetailSidebar();
|
||||
return () => window.removeEventListener("resize", handleToggleIssueDetailSidebar);
|
||||
}, [issueDetailSidebarCollapsed, toggleIssueDetailSidebar]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
{error ? (
|
||||
<EmptyState
|
||||
image={resolvedTheme === "dark" ? emptyIssueDark : emptyIssueLight}
|
||||
title="Issue does not exist"
|
||||
description="The issue you are looking for does not exist or has been deleted."
|
||||
primaryButton={{
|
||||
text: "View other issues",
|
||||
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/issues`),
|
||||
}}
|
||||
/>
|
||||
) : issueLoader ? (
|
||||
<Loader className="flex h-full gap-5 p-5">
|
||||
<div className="basis-2/3 space-y-2">
|
||||
<Loader.Item height="30px" width="40%" />
|
||||
<Loader.Item height="15px" width="60%" />
|
||||
<Loader.Item height="15px" width="60%" />
|
||||
<Loader.Item height="15px" width="40%" />
|
||||
</div>
|
||||
<div className="basis-1/3 space-y-3">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
</div>
|
||||
</Loader>
|
||||
) : (
|
||||
workspaceSlug &&
|
||||
projectId &&
|
||||
issueId && (
|
||||
<IssueDetailRoot
|
||||
swrIssueDetails={swrIssueDetails}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
issueId={issueId.toString()}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default IssueDetailsPage;
|
||||
38
web/app/[workspaceSlug]/projects/[projectId]/issues/page.tsx
Normal file
38
web/app/[workspaceSlug]/projects/[projectId]/issues/page.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import Head from "next/head";
|
||||
import { useParams } from "next/navigation";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProjectLayoutRoot } from "@/components/issues";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
|
||||
const ProjectIssuesPage = observer(() => {
|
||||
const { projectId } = useParams();
|
||||
// store
|
||||
const { getProjectById } = useProject();
|
||||
|
||||
if (!projectId) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
// derived values
|
||||
const project = getProjectById(projectId.toString());
|
||||
const pageTitle = project?.name ? `${project?.name} - Issues` : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<Head>
|
||||
<title>{project?.name} - Issues</title>
|
||||
</Head>
|
||||
<div className="h-full w-full">
|
||||
<ProjectLayoutRoot />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectIssuesPage;
|
||||
13
web/app/[workspaceSlug]/projects/[projectId]/layout.tsx
Normal file
13
web/app/[workspaceSlug]/projects/[projectId]/layout.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { ReactNode } from "react"
|
||||
// layouts
|
||||
import { ProjectAuthWrapper } from "@/layouts/auth-layout"
|
||||
|
||||
const ProjectLayout = ({ children }: { children: ReactNode }) => (
|
||||
<ProjectAuthWrapper>
|
||||
{children}
|
||||
</ProjectAuthWrapper>
|
||||
);
|
||||
|
||||
export default ProjectLayout;
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { EmptyState } from "@/components/common";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ModuleLayoutRoot } from "@/components/issues";
|
||||
import { ModuleDetailsSidebar } from "@/components/modules";
|
||||
// hooks
|
||||
import { useModule, useProject } from "@/hooks/store";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
// assets
|
||||
import emptyModule from "public/empty-state/module.svg";
|
||||
|
||||
const ModuleIssuesPage = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, moduleId } = useParams();
|
||||
// store hooks
|
||||
const { fetchModuleDetails, getModuleById } = useModule();
|
||||
const { getProjectById } = useProject();
|
||||
// local storage
|
||||
const { setValue, storedValue } = useLocalStorage("module_sidebar_collapsed", "false");
|
||||
const isSidebarCollapsed = storedValue ? (storedValue === "true" ? true : false) : false;
|
||||
// fetching module details
|
||||
const { error } = useSWR(
|
||||
workspaceSlug && projectId && moduleId ? `CURRENT_MODULE_DETAILS_${moduleId.toString()}` : null,
|
||||
workspaceSlug && projectId && moduleId
|
||||
? () => fetchModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString())
|
||||
: null
|
||||
);
|
||||
// derived values
|
||||
const projectModule = moduleId ? getModuleById(moduleId.toString()) : undefined;
|
||||
const project = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
const pageTitle = project?.name && projectModule?.name ? `${project?.name} - ${projectModule?.name}` : undefined;
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setValue(`${!isSidebarCollapsed}`);
|
||||
};
|
||||
|
||||
if (!workspaceSlug || !projectId || !moduleId) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
{error ? (
|
||||
<EmptyState
|
||||
image={emptyModule}
|
||||
title="Module does not exist"
|
||||
description="The module you are looking for does not exist or has been deleted."
|
||||
primaryButton={{
|
||||
text: "View other modules",
|
||||
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/modules`),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full">
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<ModuleLayoutRoot />
|
||||
</div>
|
||||
{moduleId && !isSidebarCollapsed && (
|
||||
<div
|
||||
className="flex h-full w-[24rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-6 duration-300 vertical-scrollbar scrollbar-sm"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",
|
||||
}}
|
||||
>
|
||||
<ModuleDetailsSidebar moduleId={moduleId.toString()} handleClose={toggleSidebar} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ModuleIssuesPage;
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// types
|
||||
import { TModuleFilters } from "@plane/types";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { ModuleAppliedFiltersList, ModulesListView } from "@/components/modules";
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
// helpers
|
||||
import { calculateTotalFilters } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import { useModuleFilter, useProject } from "@/hooks/store";
|
||||
|
||||
const ProjectModulesPage = observer(() => {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store
|
||||
const { getProjectById, currentProjectDetails } = useProject();
|
||||
const { currentProjectFilters, currentProjectDisplayFilters, clearAllFilters, updateFilters, updateDisplayFilters } =
|
||||
useModuleFilter();
|
||||
// derived values
|
||||
const project = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
const pageTitle = project?.name ? `${project?.name} - Modules` : undefined;
|
||||
|
||||
const handleRemoveFilter = useCallback(
|
||||
(key: keyof TModuleFilters, value: string | null) => {
|
||||
if (!projectId) return;
|
||||
let newValues = currentProjectFilters?.[key] ?? [];
|
||||
|
||||
if (!value) newValues = [];
|
||||
else newValues = newValues.filter((val) => val !== value);
|
||||
|
||||
updateFilters(projectId.toString(), { [key]: newValues });
|
||||
},
|
||||
[currentProjectFilters, projectId, updateFilters]
|
||||
);
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
|
||||
// No access to
|
||||
if (currentProjectDetails?.module_view === false)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<EmptyState
|
||||
type={EmptyStateType.DISABLED_PROJECT_MODULE}
|
||||
primaryButtonLink={`/${workspaceSlug}/projects/${projectId}/settings/features`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="h-full w-full flex flex-col">
|
||||
{(calculateTotalFilters(currentProjectFilters ?? {}) !== 0 || currentProjectDisplayFilters?.favorites) && (
|
||||
<div className="border-b border-custom-border-200 px-5 py-3">
|
||||
<ModuleAppliedFiltersList
|
||||
appliedFilters={currentProjectFilters ?? {}}
|
||||
isFavoriteFilterApplied={currentProjectDisplayFilters?.favorites ?? false}
|
||||
handleClearAllFilters={() => clearAllFilters(`${projectId}`)}
|
||||
handleRemoveFilter={handleRemoveFilter}
|
||||
handleDisplayFiltersUpdate={(val) => {
|
||||
if (!projectId) return;
|
||||
updateDisplayFilters(projectId.toString(), val);
|
||||
}}
|
||||
alwaysAllowEditing
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ModulesListView />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectModulesPage;
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// document-editor
|
||||
import { EditorRefApi, useEditorMarkings } from "@plane/document-editor";
|
||||
// types
|
||||
import { TPage } from "@plane/types";
|
||||
// ui
|
||||
import { TOAST_TYPE, getButtonStyling, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { IssuePeekOverview } from "@/components/issues";
|
||||
import { PageEditorBody, PageEditorHeaderRoot } from "@/components/pages";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { usePage, useProjectPages } from "@/hooks/store";
|
||||
|
||||
const PageDetailsPage = observer(() => {
|
||||
// states
|
||||
const [sidePeekVisible, setSidePeekVisible] = useState(window.innerWidth >= 768 ? true : false);
|
||||
const [editorReady, setEditorReady] = useState(false);
|
||||
const [readOnlyEditorReady, setReadOnlyEditorReady] = useState(false);
|
||||
// refs
|
||||
const editorRef = useRef<EditorRefApi>(null);
|
||||
const readOnlyEditorRef = useRef<EditorRefApi>(null);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, pageId } = useParams();
|
||||
// store hooks
|
||||
const { createPage, getPageById } = useProjectPages();
|
||||
const page = usePage(pageId?.toString() ?? "");
|
||||
const { access, description_html, id, name } = page;
|
||||
// editor markings hook
|
||||
const { markings, updateMarkings } = useEditorMarkings();
|
||||
// fetch page details
|
||||
const { error: pageDetailsError } = useSWR(
|
||||
pageId ? `PAGE_DETAILS_${pageId}` : null,
|
||||
pageId ? () => getPageById(pageId.toString()) : null,
|
||||
{
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
}
|
||||
);
|
||||
|
||||
if ((!page || !id) && !pageDetailsError)
|
||||
return (
|
||||
<div className="size-full grid place-items-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (pageDetailsError)
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col items-center justify-center">
|
||||
<h3 className="text-lg font-semibold text-center">Page not found</h3>
|
||||
<p className="text-sm text-custom-text-200 text-center mt-3">
|
||||
The page you are trying to access doesn{"'"}t exist or you don{"'"}t have permission to view it.
|
||||
</p>
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${projectId}/pages`}
|
||||
className={cn(getButtonStyling("neutral-primary", "md"), "mt-5")}
|
||||
>
|
||||
View other Pages
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleCreatePage = async (payload: Partial<TPage>) => await createPage(payload);
|
||||
|
||||
const handleDuplicatePage = async () => {
|
||||
const formData: Partial<TPage> = {
|
||||
name: "Copy of " + name,
|
||||
description_html: description_html ?? "<p></p>",
|
||||
access,
|
||||
};
|
||||
|
||||
await handleCreatePage(formData)
|
||||
.then((res) => router.push(`/${workspaceSlug}/projects/${projectId}/pages/${res?.id}`))
|
||||
.catch(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Page could not be duplicated. Please try again later.",
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
{projectId && (
|
||||
<PageEditorHeaderRoot
|
||||
editorRef={editorRef}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
editorReady={editorReady}
|
||||
readOnlyEditorReady={readOnlyEditorReady}
|
||||
handleDuplicatePage={handleDuplicatePage}
|
||||
markings={markings}
|
||||
page={page}
|
||||
projectId={projectId.toString()}
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
setSidePeekVisible={(state) => setSidePeekVisible(state)}
|
||||
/>
|
||||
)}
|
||||
<PageEditorBody
|
||||
editorRef={editorRef}
|
||||
handleEditorReady={(val) => setEditorReady(val)}
|
||||
readOnlyEditorRef={readOnlyEditorRef}
|
||||
handleReadOnlyEditorReady={() => setReadOnlyEditorReady(true)}
|
||||
markings={markings}
|
||||
page={page}
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
updateMarkings={updateMarkings}
|
||||
/>
|
||||
<IssuePeekOverview />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default PageDetailsPage;
|
||||
50
web/app/[workspaceSlug]/projects/[projectId]/pages/page.tsx
Normal file
50
web/app/[workspaceSlug]/projects/[projectId]/pages/page.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
// types
|
||||
import { TPageNavigationTabs } from "@plane/types";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { PagesListRoot, PagesListView } from "@/components/pages";
|
||||
// hooks
|
||||
import { useAppRouter, useProject } from "@/hooks/store";
|
||||
|
||||
const ProjectPagesPage = observer(() => {
|
||||
// router
|
||||
const searchParams = useSearchParams();
|
||||
const type = searchParams.get("type");
|
||||
// store hooks
|
||||
const { workspaceSlug, projectId } = useAppRouter();
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const project = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
const pageTitle = project?.name ? `${project?.name} - Pages` : undefined;
|
||||
|
||||
const currentPageType = (): TPageNavigationTabs => {
|
||||
const pageType = type?.toString();
|
||||
if (pageType === "private") return "private";
|
||||
if (pageType === "archived") return "archived";
|
||||
return "public";
|
||||
};
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<PagesListView
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
pageType={currentPageType()}
|
||||
>
|
||||
<PagesListRoot
|
||||
pageType={currentPageType()}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
/>
|
||||
</PagesListView>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectPagesPage;
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { IProject } from "@plane/types";
|
||||
// ui
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// components
|
||||
import { AutoArchiveAutomation, AutoCloseAutomation } from "@/components/automation";
|
||||
import { PageHead } from "@/components/core";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// hooks
|
||||
import { useProject, useUser } from "@/hooks/store";
|
||||
|
||||
const AutomationSettingsPage = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { currentProjectDetails: projectDetails, updateProject } = useProject();
|
||||
|
||||
const handleChange = async (formData: Partial<IProject>) => {
|
||||
if (!workspaceSlug || !projectId || !projectDetails) return;
|
||||
|
||||
await updateProject(workspaceSlug.toString(), projectId.toString(), formData).catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Something went wrong. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// derived values
|
||||
const isAdmin = currentProjectRole === EUserProjectRoles.ADMIN;
|
||||
const pageTitle = projectDetails?.name ? `${projectDetails?.name} - Automations` : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className={`w-full overflow-y-auto py-8 pr-9 ${isAdmin ? "" : "opacity-60"}`}>
|
||||
<div className="flex items-center border-b border-custom-border-100 py-3.5">
|
||||
<h3 className="text-xl font-medium">Automations</h3>
|
||||
</div>
|
||||
<AutoArchiveAutomation handleChange={handleChange} />
|
||||
<AutoCloseAutomation handleChange={handleChange} />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default AutomationSettingsPage;
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { EstimateRoot } from "@/components/estimates";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// hooks
|
||||
import { useUser, useProject } from "@/hooks/store";
|
||||
|
||||
const EstimatesSettingsPage = observer(() => {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
const { currentProjectDetails } = useProject();
|
||||
|
||||
// derived values
|
||||
const isAdmin = currentProjectRole === EUserProjectRoles.ADMIN;
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Estimates` : undefined;
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className={`w-full overflow-y-auto py-8 pr-9 ${isAdmin ? "" : "pointer-events-none opacity-60"}`}>
|
||||
<EstimateRoot workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} isAdmin={isAdmin} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default EstimatesSettingsPage;
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProjectFeaturesList } from "@/components/project";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// hooks
|
||||
import { useProject, useUser } from "@/hooks/store";
|
||||
|
||||
const FeaturesSettingsPage = observer(() => {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store
|
||||
const {
|
||||
membership: { fetchUserProjectInfo },
|
||||
} = useUser();
|
||||
const { currentProjectDetails } = useProject();
|
||||
// fetch the project details
|
||||
const { data: memberDetails } = useSWR(
|
||||
workspaceSlug && projectId ? `PROJECT_MEMBERS_ME_${workspaceSlug}_${projectId}` : null,
|
||||
workspaceSlug && projectId ? () => fetchUserProjectInfo(workspaceSlug.toString(), projectId.toString()) : null
|
||||
);
|
||||
// derived values
|
||||
const isAdmin = memberDetails?.role === EUserProjectRoles.ADMIN;
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Features` : undefined;
|
||||
|
||||
if (!workspaceSlug || !projectId) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className={`w-full overflow-y-auto py-8 pr-9 ${isAdmin ? "" : "opacity-60"}`}>
|
||||
<div className="flex items-center border-b border-custom-border-100 py-3.5">
|
||||
<h3 className="text-xl font-medium">Features</h3>
|
||||
</div>
|
||||
<ProjectFeaturesList
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default FeaturesSettingsPage;
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
|
||||
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProjectSettingsLabelList } from "@/components/labels";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
|
||||
const LabelsSettingsPage = observer(() => {
|
||||
const { currentProjectDetails } = useProject();
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Labels` : undefined;
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Enable Auto Scroll for Labels list
|
||||
useEffect(() => {
|
||||
const element = scrollableContainerRef.current;
|
||||
|
||||
if (!element) return;
|
||||
|
||||
return combine(
|
||||
autoScrollForElements({
|
||||
element,
|
||||
})
|
||||
);
|
||||
}, [scrollableContainerRef?.current]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div ref={scrollableContainerRef} className="h-full w-full gap-10 overflow-y-auto py-8 pr-9">
|
||||
<ProjectSettingsLabelList />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default LabelsSettingsPage;
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
"use client";
|
||||
|
||||
import { FC, ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
// ui
|
||||
import { Button, LayersIcon } from "@plane/ui";
|
||||
// components
|
||||
import { NotAuthorizedView } from "@/components/auth-screens";
|
||||
// constants
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store";
|
||||
// local components
|
||||
import ProjectSettingsSidebar from "./sidebar";
|
||||
|
||||
export interface IProjectSettingLayout {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const ProjectSettingLayout: FC<IProjectSettingLayout> = observer((props) => {
|
||||
const { children } = props;
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
|
||||
const restrictViewSettings = currentProjectRole && currentProjectRole <= EUserProjectRoles.VIEWER;
|
||||
|
||||
return restrictViewSettings ? (
|
||||
<NotAuthorizedView
|
||||
type="project"
|
||||
actionButton={
|
||||
//TODO: Create a new component called Button Link to handle such scenarios
|
||||
<Link href={`/${workspaceSlug}/projects/${projectId}/issues`}>
|
||||
<Button variant="primary" size="md" prependIcon={<LayersIcon />}>
|
||||
Go to issues
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="inset-y-0 z-20 flex flex-grow-0 h-full w-full">
|
||||
<div className="w-80 flex-shrink-0 overflow-y-hidden pt-8 sm:hidden hidden md:block lg:block">
|
||||
<ProjectSettingsSidebar />
|
||||
</div>
|
||||
<div className="w-full pl-10 sm:pl-10 md:pl-0 lg:pl-0 overflow-x-hidden overflow-y-scroll vertical-scrollbar scrollbar-md">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectSettingLayout;
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProjectMemberList, ProjectSettingsMemberDefaults } from "@/components/project";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
|
||||
const MembersSettingsPage = observer(() => {
|
||||
// store
|
||||
const { currentProjectDetails } = useProject();
|
||||
// derived values
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - Members` : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className={`w-full overflow-y-auto py-8 pr-9`}>
|
||||
<ProjectSettingsMemberDefaults />
|
||||
<ProjectMemberList />
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default MembersSettingsPage;
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import {
|
||||
ArchiveRestoreProjectModal,
|
||||
ArchiveProjectSelection,
|
||||
DeleteProjectModal,
|
||||
DeleteProjectSection,
|
||||
ProjectDetailsForm,
|
||||
ProjectDetailsFormLoader,
|
||||
} from "@/components/project";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
|
||||
const GeneralSettingsPage = observer(() => {
|
||||
// states
|
||||
const [selectProject, setSelectedProject] = useState<string | null>(null);
|
||||
const [archiveProject, setArchiveProject] = useState<boolean>(false);
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const { currentProjectDetails, fetchProjectDetails } = useProject();
|
||||
// api call to fetch project details
|
||||
// TODO: removed this API if not necessary
|
||||
const { isLoading } = useSWR(
|
||||
workspaceSlug && projectId ? `PROJECT_DETAILS_${projectId}` : null,
|
||||
workspaceSlug && projectId ? () => fetchProjectDetails(workspaceSlug.toString(), projectId.toString()) : null
|
||||
);
|
||||
// derived values
|
||||
const isAdmin = currentProjectDetails?.member_role === 20;
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - General Settings` : undefined;
|
||||
// const currentNetwork = NETWORK_CHOICES.find((n) => n.key === projectDetails?.network);
|
||||
// const selectedNetwork = NETWORK_CHOICES.find((n) => n.key === watch("network"));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
{currentProjectDetails && workspaceSlug && projectId && (
|
||||
<>
|
||||
<ArchiveRestoreProjectModal
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
isOpen={archiveProject}
|
||||
onClose={() => setArchiveProject(false)}
|
||||
archive
|
||||
/>
|
||||
<DeleteProjectModal
|
||||
project={currentProjectDetails}
|
||||
isOpen={Boolean(selectProject)}
|
||||
onClose={() => setSelectedProject(null)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={`w-full overflow-y-auto py-8 pr-9 ${isAdmin ? "" : "opacity-60"}`}>
|
||||
{currentProjectDetails && workspaceSlug && projectId && !isLoading ? (
|
||||
<ProjectDetailsForm
|
||||
project={currentProjectDetails}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
) : (
|
||||
<ProjectDetailsFormLoader />
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<ArchiveProjectSelection
|
||||
projectDetails={currentProjectDetails}
|
||||
handleArchive={() => setArchiveProject(true)}
|
||||
/>
|
||||
<DeleteProjectSection
|
||||
projectDetails={currentProjectDetails}
|
||||
handleDelete={() => setSelectedProject(currentProjectDetails.id ?? null)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default GeneralSettingsPage;
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
// ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// constants
|
||||
import { EUserProjectRoles, PROJECT_SETTINGS_LINKS } from "@/constants/project";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store";
|
||||
|
||||
const ProjectSettingsSidebar = () => {
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
const pathname = usePathname();
|
||||
// mobx store
|
||||
const {
|
||||
membership: { currentProjectRole },
|
||||
} = useUser();
|
||||
|
||||
const projectMemberInfo = currentProjectRole || EUserProjectRoles.GUEST;
|
||||
|
||||
if (!currentProjectRole) {
|
||||
return (
|
||||
<div className="flex w-80 flex-col gap-6 px-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-semibold text-custom-sidebar-text-400">SETTINGS</span>
|
||||
<Loader className="flex w-full flex-col gap-2">
|
||||
{[...Array(8)].map((index) => (
|
||||
<Loader.Item key={index} height="34px" />
|
||||
))}
|
||||
</Loader>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-80 flex-col gap-6 px-5">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-semibold text-custom-sidebar-text-400">SETTINGS</span>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{PROJECT_SETTINGS_LINKS.map(
|
||||
(link) =>
|
||||
projectMemberInfo >= link.access && (
|
||||
<Link key={link.key} href={`/${workspaceSlug}/projects/${projectId}${link.href}`}>
|
||||
<div
|
||||
className={`rounded-md px-4 py-2 text-sm font-medium ${link.highlight(pathname, `/${workspaceSlug}/projects/${projectId}`)
|
||||
? "bg-custom-primary-100/10 text-custom-primary-100"
|
||||
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectSettingsSidebar;
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProjectSettingStateList } from "@/components/states";
|
||||
// hook
|
||||
import { useProject } from "@/hooks/store";
|
||||
|
||||
const StatesSettingsPage = observer(() => {
|
||||
// store
|
||||
const { currentProjectDetails } = useProject();
|
||||
// derived values
|
||||
const pageTitle = currentProjectDetails?.name ? `${currentProjectDetails?.name} - States` : undefined;
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="w-full gap-10 overflow-y-auto py-8 pr-9">
|
||||
<div className="flex items-center border-b border-custom-border-100 py-3.5">
|
||||
<h3 className="text-xl font-medium">States</h3>
|
||||
</div>
|
||||
<ProjectSettingStateList />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default StatesSettingsPage;
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { EmptyState } from "@/components/common";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProjectViewLayoutRoot } from "@/components/issues";
|
||||
// hooks
|
||||
import { useProject, useProjectView } from "@/hooks/store";
|
||||
// assets
|
||||
import emptyView from "public/empty-state/view.svg";
|
||||
|
||||
const ProjectViewIssuesPage = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, viewId } = useParams();
|
||||
// store hooks
|
||||
const { fetchViewDetails, getViewById } = useProjectView();
|
||||
const { getProjectById } = useProject();
|
||||
// derived values
|
||||
const projectView = viewId ? getViewById(viewId.toString()) : undefined;
|
||||
const project = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
const pageTitle = project?.name && projectView?.name ? `${project?.name} - ${projectView?.name}` : undefined;
|
||||
|
||||
const { error } = useSWR(
|
||||
workspaceSlug && projectId && viewId ? `VIEW_DETAILS_${viewId.toString()}` : null,
|
||||
workspaceSlug && projectId && viewId
|
||||
? () => fetchViewDetails(workspaceSlug.toString(), projectId.toString(), viewId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{error ? (
|
||||
<EmptyState
|
||||
image={emptyView}
|
||||
title="View does not exist"
|
||||
description="The view you are looking for does not exist or has been deleted."
|
||||
primaryButton={{
|
||||
text: "View other views",
|
||||
onClick: () => router.push(`/${workspaceSlug}/projects/${projectId}/views`),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<ProjectViewLayoutRoot />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectViewIssuesPage;
|
||||
44
web/app/[workspaceSlug]/projects/[projectId]/views/page.tsx
Normal file
44
web/app/[workspaceSlug]/projects/[projectId]/views/page.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { ProjectViewsList } from "@/components/views";
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store";
|
||||
|
||||
const ProjectViewsPage = observer(() => {
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store
|
||||
const { getProjectById, currentProjectDetails } = useProject();
|
||||
// derived values
|
||||
const project = projectId ? getProjectById(projectId.toString()) : undefined;
|
||||
const pageTitle = project?.name ? `${project?.name} - Views` : undefined;
|
||||
|
||||
if (!workspaceSlug || !projectId) return <></>;
|
||||
|
||||
// No access to
|
||||
if (currentProjectDetails?.issue_views_view === false)
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<EmptyState
|
||||
type={EmptyStateType.DISABLED_PROJECT_VIEW}
|
||||
primaryButtonLink={`/${workspaceSlug}/projects/${projectId}/settings/features`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<ProjectViewsList />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectViewsPage;
|
||||
83
web/app/[workspaceSlug]/projects/page.tsx
Normal file
83
web/app/[workspaceSlug]/projects/page.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import { TProjectAppliedDisplayFilterKeys, TProjectFilters } from "@plane/types";
|
||||
// components
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ProjectAppliedFiltersList, ProjectCardList } from "@/components/project";
|
||||
// helpers
|
||||
import { calculateTotalFilters } from "@/helpers/filter.helper";
|
||||
// hooks
|
||||
import { useAppRouter, useProject, useProjectFilter, useWorkspace } from "@/hooks/store";
|
||||
|
||||
const ProjectsPage = observer(() => {
|
||||
// store
|
||||
const { workspaceSlug } = useAppRouter();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { totalProjectIds, filteredProjectIds } = useProject();
|
||||
const {
|
||||
currentWorkspaceFilters,
|
||||
currentWorkspaceAppliedDisplayFilters,
|
||||
clearAllFilters,
|
||||
clearAllAppliedDisplayFilters,
|
||||
updateFilters,
|
||||
updateDisplayFilters,
|
||||
} = useProjectFilter();
|
||||
// derived values
|
||||
const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Projects` : undefined;
|
||||
|
||||
const handleRemoveFilter = useCallback(
|
||||
(key: keyof TProjectFilters, value: string | null) => {
|
||||
if (!workspaceSlug) return;
|
||||
let newValues = currentWorkspaceFilters?.[key] ?? [];
|
||||
|
||||
if (!value) newValues = [];
|
||||
else newValues = newValues.filter((val) => val !== value);
|
||||
|
||||
updateFilters(workspaceSlug.toString(), { [key]: newValues });
|
||||
},
|
||||
[currentWorkspaceFilters, updateFilters, workspaceSlug]
|
||||
);
|
||||
|
||||
const handleRemoveDisplayFilter = useCallback(
|
||||
(key: TProjectAppliedDisplayFilterKeys) => {
|
||||
if (!workspaceSlug) return;
|
||||
updateDisplayFilters(workspaceSlug.toString(), { [key]: false });
|
||||
},
|
||||
[updateDisplayFilters, workspaceSlug]
|
||||
);
|
||||
|
||||
const handleClearAllFilters = useCallback(() => {
|
||||
if (!workspaceSlug) return;
|
||||
clearAllFilters(workspaceSlug.toString());
|
||||
clearAllAppliedDisplayFilters(workspaceSlug.toString());
|
||||
}, [clearAllFilters, clearAllAppliedDisplayFilters, workspaceSlug]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="flex h-full w-full flex-col">
|
||||
{(calculateTotalFilters(currentWorkspaceFilters ?? {}) !== 0 ||
|
||||
currentWorkspaceAppliedDisplayFilters?.length !== 0) && (
|
||||
<div className="border-b border-custom-border-200 px-5 py-3">
|
||||
<ProjectAppliedFiltersList
|
||||
appliedFilters={currentWorkspaceFilters ?? {}}
|
||||
appliedDisplayFilters={currentWorkspaceAppliedDisplayFilters ?? []}
|
||||
handleClearAllFilters={handleClearAllFilters}
|
||||
handleRemoveFilter={handleRemoveFilter}
|
||||
handleRemoveDisplayFilter={handleRemoveDisplayFilter}
|
||||
filteredProjects={filteredProjectIds?.length ?? 0}
|
||||
totalProjects={totalProjectIds?.length ?? 0}
|
||||
alwaysAllowEditing
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ProjectCardList />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ProjectsPage;
|
||||
95
web/app/[workspaceSlug]/settings/api-tokens/page.tsx
Normal file
95
web/app/[workspaceSlug]/settings/api-tokens/page.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// ui
|
||||
import { Button } from "@plane/ui";
|
||||
// component
|
||||
import { ApiTokenListItem, CreateApiTokenModal } from "@/components/api-token";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { APITokenSettingsLoader } from "@/components/ui";
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
import { API_TOKENS_LIST } from "@/constants/fetch-keys";
|
||||
import { EUserWorkspaceRoles } from "@/constants/workspace";
|
||||
// store hooks
|
||||
import { useUser, useWorkspace } from "@/hooks/store";
|
||||
// services
|
||||
import { APITokenService } from "@/services/api_token.service";
|
||||
|
||||
const apiTokenService = new APITokenService();
|
||||
|
||||
const ApiTokensPage = observer(() => {
|
||||
// states
|
||||
const [isCreateTokenModalOpen, setIsCreateTokenModalOpen] = useState(false);
|
||||
// router
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const {
|
||||
membership: { currentWorkspaceRole },
|
||||
} = useUser();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN;
|
||||
|
||||
const { data: tokens } = useSWR(workspaceSlug && isAdmin ? API_TOKENS_LIST(workspaceSlug.toString()) : null, () =>
|
||||
workspaceSlug && isAdmin ? apiTokenService.getApiTokens(workspaceSlug.toString()) : null
|
||||
);
|
||||
|
||||
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - API Tokens` : undefined;
|
||||
|
||||
if (!isAdmin)
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="mt-10 flex h-full w-full justify-center p-4">
|
||||
<p className="text-sm text-custom-text-300">You are not authorized to access this page.</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!tokens) {
|
||||
return <APITokenSettingsLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<CreateApiTokenModal isOpen={isCreateTokenModalOpen} onClose={() => setIsCreateTokenModalOpen(false)} />
|
||||
<section className="w-full overflow-y-auto md:pr-9 pr-4">
|
||||
{tokens.length > 0 ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between border-b border-custom-border-200 py-3.5">
|
||||
<h3 className="text-xl font-medium">API tokens</h3>
|
||||
<Button variant="primary" onClick={() => setIsCreateTokenModalOpen(true)}>
|
||||
Add API token
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
{tokens.map((token) => (
|
||||
<ApiTokenListItem key={token.id} token={token} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-custom-border-200 pb-3.5">
|
||||
<h3 className="text-xl font-medium">API tokens</h3>
|
||||
<Button variant="primary" onClick={() => setIsCreateTokenModalOpen(true)}>
|
||||
Add API token
|
||||
</Button>
|
||||
</div>
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<EmptyState type={EmptyStateType.WORKSPACE_SETTINGS_API_TOKENS} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default ApiTokensPage;
|
||||
76
web/app/[workspaceSlug]/settings/billing/page.tsx
Normal file
76
web/app/[workspaceSlug]/settings/billing/page.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<<<<<<< HEAD:web/app/[workspaceSlug]/settings/billing/page.tsx
|
||||
"use client";
|
||||
=======
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { Button } from "@plane/ui";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { WorkspaceSettingHeader } from "@/components/headers";
|
||||
import { MARKETING_PRICING_PAGE_LINK } from "@/constants/common";
|
||||
import { EUserWorkspaceRoles } from "@/constants/workspace";
|
||||
import { useUser, useWorkspace } from "@/hooks/store";
|
||||
// layouts
|
||||
import { AppLayout } from "@/layouts/app-layout";
|
||||
import { WorkspaceSettingLayout } from "@/layouts/settings-layout";
|
||||
// component
|
||||
// ui
|
||||
// types
|
||||
import { NextPageWithLayout } from "@/lib/types";
|
||||
// constants
|
||||
>>>>>>> 59fdd611e4b840993e93fc53c89a75a3787f248d:web/pages/[workspaceSlug]/settings/billing.tsx
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { Button } from "@plane/ui";
|
||||
// component
|
||||
import { PageHead } from "@/components/core";
|
||||
// constants
|
||||
import { MARKETING_PRICING_PAGE_LINK } from "@/constants/common";
|
||||
import { EUserWorkspaceRoles } from "@/constants/workspace";
|
||||
// hooks
|
||||
import { useUser, useWorkspace } from "@/hooks/store";
|
||||
|
||||
const BillingSettingsPage = observer(() => {
|
||||
// store hooks
|
||||
const {
|
||||
membership: { currentWorkspaceRole },
|
||||
} = useUser();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
// derived values
|
||||
const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN;
|
||||
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Billing & Plans` : undefined;
|
||||
|
||||
if (!isAdmin)
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<div className="mt-10 flex h-full w-full justify-center p-4">
|
||||
<p className="text-sm text-custom-text-300">You are not authorized to access this page.</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
<section className="w-full overflow-y-auto md:pr-9 pr-4">
|
||||
<div>
|
||||
<div className="flex items-center border-b border-custom-border-100 py-3.5">
|
||||
<h3 className="text-xl font-medium">Billing & Plans</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-6">
|
||||
<div>
|
||||
<h4 className="text-md mb-1 leading-6">Current plan</h4>
|
||||
<p className="mb-3 text-sm text-custom-text-200">You are currently using the free plan</p>
|
||||
<a href={MARKETING_PRICING_PAGE_LINK} target="_blank" rel="noreferrer">
|
||||
<Button variant="neutral-primary">View Plans</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default BillingSettingsPage;
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue