[WEB-1255] chore: Required Spaces refactor (#5177)
* Changes required to enable Publish Views * default views to not found page * refactor exports * remove uncessary view service * fix review comments
This commit is contained in:
parent
2ee6cd20d8
commit
8577a56068
77 changed files with 2772 additions and 484 deletions
2
packages/types/src/view-props.d.ts
vendored
2
packages/types/src/view-props.d.ts
vendored
|
|
@ -202,4 +202,6 @@ export interface IssuePaginationOptions {
|
|||
before?: string;
|
||||
after?: string;
|
||||
groupedBy?: TIssueGroupByOptions;
|
||||
subGroupedBy?: TIssueGroupByOptions;
|
||||
orderBy?: TIssueOrderByOptions;
|
||||
}
|
||||
|
|
|
|||
12
packages/types/src/views.d.ts
vendored
12
packages/types/src/views.d.ts
vendored
|
|
@ -25,9 +25,21 @@ export interface IProjectView {
|
|||
workspace: string;
|
||||
logo_props: TLogoProps | undefined;
|
||||
is_locked: boolean;
|
||||
anchor?: string;
|
||||
owned_by: string;
|
||||
}
|
||||
|
||||
export type TPublishViewSettings = {
|
||||
is_comments_enabled: boolean;
|
||||
is_reactions_enabled: boolean;
|
||||
is_votes_enabled: boolean;
|
||||
};
|
||||
|
||||
export type TPublishViewDetails = TPublishViewSettings & {
|
||||
id: string;
|
||||
anchor: string;
|
||||
};
|
||||
|
||||
export type TViewFiltersSortKey = "name" | "created_at" | "updated_at";
|
||||
|
||||
export type TViewFiltersSortBy = "asc" | "desc";
|
||||
|
|
|
|||
75
space/app/views/[anchor]/layout.tsx
Normal file
75
space/app/views/[anchor]/layout.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { LogoSpinner } from "@/components/common";
|
||||
// hooks
|
||||
import { usePublish, usePublishList } from "@/hooks/store";
|
||||
// Plane web
|
||||
import { ViewNavbarRoot } from "@/plane-web/components/navbar";
|
||||
import { useView } from "@/plane-web/hooks/store";
|
||||
// assets
|
||||
import planeLogo from "@/public/plane-logo.svg";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
params: {
|
||||
anchor: string;
|
||||
};
|
||||
};
|
||||
|
||||
const IssuesLayout = observer((props: Props) => {
|
||||
const { children, params } = props;
|
||||
// params
|
||||
const { anchor } = params;
|
||||
// store hooks
|
||||
const { fetchPublishSettings } = usePublishList();
|
||||
const { viewData, fetchViewDetails } = useView();
|
||||
const publishSettings = usePublish(anchor);
|
||||
// fetch publish settings
|
||||
useSWR(
|
||||
anchor ? `PUBLISH_SETTINGS_${anchor}` : null,
|
||||
anchor
|
||||
? async () => {
|
||||
await fetchPublishSettings(anchor);
|
||||
}
|
||||
: null
|
||||
);
|
||||
// fetch view data
|
||||
useSWR(
|
||||
anchor ? `VIEW_DETAILS_${anchor}` : null,
|
||||
anchor
|
||||
? async () => {
|
||||
await fetchViewDetails(anchor);
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
if (!publishSettings || !viewData) return <LogoSpinner />;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-screen min-h-[500px] w-screen flex-col overflow-hidden">
|
||||
<div className="relative flex h-[60px] flex-shrink-0 select-none items-center border-b border-custom-border-300 bg-custom-sidebar-background-100">
|
||||
<ViewNavbarRoot publishSettings={publishSettings} />
|
||||
</div>
|
||||
<div className="relative h-full w-full overflow-hidden bg-custom-background-90">{children}</div>
|
||||
<a
|
||||
href="https://plane.so"
|
||||
className="fixed bottom-2.5 right-5 !z-[999999] flex items-center gap-1 rounded border border-custom-border-200 bg-custom-background-100 px-2 py-1 shadow-custom-shadow-2xs"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
<div className="relative grid h-6 w-6 place-items-center">
|
||||
<Image src={planeLogo} alt="Plane logo" className="h-6 w-6" height="24" width="24" />
|
||||
</div>
|
||||
<div className="text-xs">
|
||||
Powered by <span className="font-semibold">Plane Publish</span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default IssuesLayout;
|
||||
30
space/app/views/[anchor]/page.tsx
Normal file
30
space/app/views/[anchor]/page.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
// hooks
|
||||
import { usePublish } from "@/hooks/store";
|
||||
// plane-web
|
||||
import { ViewLayoutsRoot } from "@/plane-web/components/issue-layouts/root";
|
||||
|
||||
type Props = {
|
||||
params: {
|
||||
anchor: string;
|
||||
};
|
||||
};
|
||||
|
||||
const IssuesPage = observer((props: Props) => {
|
||||
const { params } = props;
|
||||
const { anchor } = params;
|
||||
// params
|
||||
const searchParams = useSearchParams();
|
||||
const peekId = searchParams.get("peekId") || undefined;
|
||||
|
||||
const publishSettings = usePublish(anchor);
|
||||
|
||||
if (!publishSettings) return null;
|
||||
|
||||
return <ViewLayoutsRoot peekId={peekId} publishSettings={publishSettings} />;
|
||||
});
|
||||
|
||||
export default IssuesPage;
|
||||
10
space/ce/components/issue-layouts/root.tsx
Normal file
10
space/ce/components/issue-layouts/root.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { PageNotFound } from "@/components/ui/not-found";
|
||||
import { PublishStore } from "@/store/publish/publish.store";
|
||||
|
||||
type Props = {
|
||||
peekId: string | undefined;
|
||||
publishSettings: PublishStore;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const ViewLayoutsRoot = (props: Props) => <PageNotFound />;
|
||||
8
space/ce/components/navbar/index.tsx
Normal file
8
space/ce/components/navbar/index.tsx
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { PublishStore } from "@/store/publish/publish.store";
|
||||
|
||||
type Props = {
|
||||
publishSettings: PublishStore;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const ViewNavbarRoot = (props: Props) => <></>;
|
||||
1
space/ce/hooks/store/index.ts
Normal file
1
space/ce/hooks/store/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./use-published-view";
|
||||
5
space/ce/hooks/store/use-published-view.ts
Normal file
5
space/ce/hooks/store/use-published-view.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export const useView = () => ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
fetchViewDetails: (anchor: string) => {},
|
||||
viewData: {},
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
export * from "./kanban";
|
||||
export * from "./list";
|
||||
export * from "./kanban/base-kanban-root";
|
||||
export * from "./list/base-list-root";
|
||||
export * from "./properties";
|
||||
export * from "./root";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { observer } from "mobx-react";
|
||||
import { TLoader } from "@plane/types";
|
||||
import { LogoSpinner } from "@/components/common";
|
||||
|
||||
interface Props {
|
||||
children: string | JSX.Element | JSX.Element[];
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
|
||||
}
|
||||
|
||||
export const IssueLayoutHOC = observer((props: Props) => {
|
||||
const { getIssueLoader, getGroupIssueCount } = props;
|
||||
|
||||
const issueCount = getGroupIssueCount(undefined, undefined, false);
|
||||
|
||||
if (getIssueLoader() === "init-loader" || issueCount === undefined) {
|
||||
return (
|
||||
<div className="relative flex h-screen w-full items-center justify-center">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (getGroupIssueCount(undefined, undefined, false) === 0) {
|
||||
return <div className="flex w-full h-full items-center justify-center">No Issues Found</div>;
|
||||
}
|
||||
|
||||
return <>{props.children}</>;
|
||||
});
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import debounce from "lodash/debounce";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import { IIssueDisplayProperties } from "@plane/types";
|
||||
// components
|
||||
import { IssueLayoutHOC } from "@/components/issues/issue-layouts/issue-layout-HOC";
|
||||
// hooks
|
||||
import { useIssue } from "@/hooks/store";
|
||||
|
||||
import { KanBan } from "./default";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
};
|
||||
export const IssueKanbanLayoutRoot: React.FC<Props> = observer((props: Props) => {
|
||||
const { anchor } = props;
|
||||
// store hooks
|
||||
const { groupedIssueIds, getIssueLoader, fetchNextPublicIssues, getGroupIssueCount, getPaginationData } = useIssue();
|
||||
|
||||
const displayProperties: IIssueDisplayProperties = useMemo(
|
||||
() => ({
|
||||
key: true,
|
||||
state: true,
|
||||
labels: true,
|
||||
priority: true,
|
||||
due_date: true,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const fetchMoreIssues = useCallback(
|
||||
(groupId?: string, subgroupId?: string) => {
|
||||
if (getIssueLoader(groupId, subgroupId) !== "pagination") {
|
||||
fetchNextPublicIssues(anchor, groupId, subgroupId);
|
||||
}
|
||||
},
|
||||
[fetchNextPublicIssues]
|
||||
);
|
||||
|
||||
const debouncedFetchMoreIssues = debounce(
|
||||
(groupId?: string, subgroupId?: string) => fetchMoreIssues(groupId, subgroupId),
|
||||
300,
|
||||
{ leading: true, trailing: false }
|
||||
);
|
||||
|
||||
const scrollableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
return (
|
||||
<IssueLayoutHOC getGroupIssueCount={getGroupIssueCount} getIssueLoader={getIssueLoader}>
|
||||
<div
|
||||
className={`horizontal-scrollbar scrollbar-lg relative flex h-full w-full bg-custom-background-90 overflow-x-auto overflow-y-hidden`}
|
||||
ref={scrollableContainerRef}
|
||||
>
|
||||
<div className="relative h-full w-max min-w-full bg-custom-background-90">
|
||||
<div className="h-full w-max">
|
||||
<KanBan
|
||||
groupedIssueIds={groupedIssueIds ?? {}}
|
||||
displayProperties={displayProperties}
|
||||
subGroupBy={null}
|
||||
groupBy="state"
|
||||
showEmptyGroup
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
loadMoreIssues={debouncedFetchMoreIssues}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
getPaginationData={getPaginationData}
|
||||
getIssueLoader={getIssueLoader}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</IssueLayoutHOC>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,81 +1,101 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { MutableRefObject } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
// plane
|
||||
import { cn } from "@plane/editor";
|
||||
import { IIssueDisplayProperties } from "@plane/types";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { IssueBlockDueDate, IssueBlockPriority, IssueBlockState } from "@/components/issues";
|
||||
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/with-display-properties-HOC";
|
||||
// helpers
|
||||
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||
// hooks
|
||||
import { useIssue, useIssueDetails, usePublish } from "@/hooks/store";
|
||||
// interfaces
|
||||
import { useIssueDetails, usePublish } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
import { IIssue } from "@/types/issue";
|
||||
import { IssueProperties } from "../properties/all-properties";
|
||||
import { getIssueBlockId } from "../utils";
|
||||
|
||||
interface IssueBlockProps {
|
||||
issueId: string;
|
||||
};
|
||||
groupId: string;
|
||||
subGroupId: string;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const IssueKanBanBlock: FC<Props> = observer((props) => {
|
||||
const { anchor, issueId } = props;
|
||||
const { getIssueById } = useIssue();
|
||||
interface IssueDetailsBlockProps {
|
||||
issue: IIssue;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
}
|
||||
|
||||
const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = observer((props) => {
|
||||
const { issue, displayProperties } = props;
|
||||
const { anchor } = useParams();
|
||||
// hooks
|
||||
const { project_details } = usePublish(anchor.toString());
|
||||
|
||||
return (
|
||||
<div className="space-y-2 px-3 py-2">
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties || {}} displayPropertyKey="key">
|
||||
<div className="relative">
|
||||
<div className="line-clamp-1 text-xs text-custom-text-300">
|
||||
{project_details?.identifier}-{issue.sequence_id}
|
||||
</div>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
<div className="w-full line-clamp-1 text-sm text-custom-text-100 mb-1.5">
|
||||
<Tooltip tooltipContent={issue.name}>
|
||||
<span>{issue.name}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<IssueProperties
|
||||
className="flex flex-wrap items-center gap-2 whitespace-nowrap text-custom-text-300 pt-1.5"
|
||||
issue={issue}
|
||||
displayProperties={displayProperties}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const KanbanIssueBlock: React.FC<IssueBlockProps> = observer((props) => {
|
||||
const { issueId, groupId, subGroupId, displayProperties } = props;
|
||||
const searchParams = useSearchParams();
|
||||
// query params
|
||||
const board = searchParams.get("board");
|
||||
const state = searchParams.get("state");
|
||||
const priority = searchParams.get("priority");
|
||||
const labels = searchParams.get("labels");
|
||||
// store hooks
|
||||
const { project_details } = usePublish(anchor);
|
||||
const { setPeekId } = useIssueDetails();
|
||||
// hooks
|
||||
const { setPeekId, getIsIssuePeeked, getIssueById } = useIssueDetails();
|
||||
|
||||
const { queryParam } = queryParamGenerator({ board, peekId: issueId, priority, state, labels });
|
||||
|
||||
const handleBlockClick = () => {
|
||||
const handleIssuePeekOverview = () => {
|
||||
setPeekId(issueId);
|
||||
};
|
||||
|
||||
const { queryParam } = queryParamGenerator(board ? { board, peekId: issueId } : { peekId: issueId });
|
||||
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue) return <></>;
|
||||
if (!issue) return null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/issues/${anchor}?${queryParam}`}
|
||||
onClick={handleBlockClick}
|
||||
className="flex flex-col gap-1.5 space-y-2 rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 px-3 py-2 text-sm shadow-custom-shadow-2xs select-none"
|
||||
>
|
||||
{/* id */}
|
||||
<div className="break-words text-xs text-custom-text-300">
|
||||
{project_details?.identifier}-{issue?.sequence_id}
|
||||
</div>
|
||||
|
||||
{/* name */}
|
||||
<h6 role="button" className="line-clamp-2 cursor-pointer break-words text-sm">
|
||||
{issue.name}
|
||||
</h6>
|
||||
|
||||
<div className="hide-horizontal-scrollbar relative flex w-full flex-grow items-end gap-2 overflow-x-scroll">
|
||||
{/* priority */}
|
||||
{issue?.priority && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockPriority priority={issue?.priority} />
|
||||
</div>
|
||||
<div className={cn("group/kanban-block relative p-1.5")}>
|
||||
<Link
|
||||
id={getIssueBlockId(issueId, groupId, subGroupId)}
|
||||
className={cn(
|
||||
"block rounded border-[1px] outline-[0.5px] outline-transparent w-full border-custom-border-200 bg-custom-background-100 text-sm transition-all hover:border-custom-border-400",
|
||||
{ "border border-custom-primary-70 hover:border-custom-primary-70": getIsIssuePeeked(issue.id) }
|
||||
)}
|
||||
{/* state */}
|
||||
{issue?.state_id && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockState stateId={issue?.state_id} />
|
||||
</div>
|
||||
)}
|
||||
{/* due date */}
|
||||
{issue?.target_date && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockDueDate due_date={issue?.target_date} stateId={issue?.state_id ?? undefined} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
href={`?${queryParam}`}
|
||||
onClick={handleIssuePeekOverview}
|
||||
>
|
||||
<KanbanIssueDetailsBlock issue={issue} displayProperties={displayProperties} />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
KanbanIssueBlock.displayName = "KanbanIssueBlock";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
import { MutableRefObject } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
//types
|
||||
import { IIssueDisplayProperties } from "@plane/types";
|
||||
// components
|
||||
import { KanbanIssueBlock } from "./block";
|
||||
|
||||
interface IssueBlocksListProps {
|
||||
subGroupId: string;
|
||||
groupId: string;
|
||||
issueIds: string[];
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const KanbanIssueBlocksList: React.FC<IssueBlocksListProps> = observer((props) => {
|
||||
const { subGroupId, groupId, issueIds, displayProperties, scrollableContainerRef } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{issueIds && issueIds.length > 0 ? (
|
||||
<>
|
||||
{issueIds.map((issueId) => {
|
||||
if (!issueId) return null;
|
||||
|
||||
let draggableId = issueId;
|
||||
if (groupId) draggableId = `${draggableId}__${groupId}`;
|
||||
if (subGroupId) draggableId = `${draggableId}__${subGroupId}`;
|
||||
|
||||
return (
|
||||
<KanbanIssueBlock
|
||||
key={draggableId}
|
||||
issueId={issueId}
|
||||
groupId={groupId}
|
||||
subGroupId={subGroupId}
|
||||
displayProperties={displayProperties}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import { useCallback, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { Icon } from "@/components/ui";
|
||||
// hooks
|
||||
import { useIssue } from "@/hooks/store";
|
||||
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
|
||||
// components
|
||||
import { IssueKanBanBlock } from "./block";
|
||||
import { IssueKanBanHeader } from "./header";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
stateId: string;
|
||||
issueIds: string[];
|
||||
};
|
||||
|
||||
export const Column = observer((props: Props) => {
|
||||
const { anchor, stateId, issueIds } = props;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [intersectionElement, setIntersectionElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const { fetchNextPublicIssues, getPaginationData, getIssueLoader, getGroupIssueCount } = useIssue();
|
||||
|
||||
const loadMoreIssuesInThisGroup = useCallback(() => {
|
||||
fetchNextPublicIssues(anchor, stateId);
|
||||
}, [fetchNextPublicIssues, anchor, stateId]);
|
||||
|
||||
const isPaginating = !!getIssueLoader(stateId);
|
||||
const nextPageResults = getPaginationData(stateId, undefined)?.nextPageResults;
|
||||
|
||||
useIntersectionObserver(
|
||||
containerRef,
|
||||
isPaginating ? null : intersectionElement,
|
||||
loadMoreIssuesInThisGroup,
|
||||
`0% 100% 100% 100%`
|
||||
);
|
||||
|
||||
const groupIssueCount = getGroupIssueCount(stateId, undefined, false);
|
||||
const shouldLoadMore =
|
||||
nextPageResults === undefined && groupIssueCount !== undefined
|
||||
? issueIds?.length < groupIssueCount
|
||||
: !!nextPageResults;
|
||||
|
||||
return (
|
||||
<div key={stateId} className="relative flex h-full w-[340px] flex-shrink-0 flex-col">
|
||||
<div className="flex-shrink-0">
|
||||
<IssueKanBanHeader stateId={stateId} />
|
||||
</div>
|
||||
<div className="hide-vertical-scrollbar h-full w-full overflow-hidden overflow-y-auto" ref={containerRef}>
|
||||
{issueIds && issueIds.length > 0 ? (
|
||||
<div className="space-y-3 px-2 pb-2">
|
||||
{issueIds.map((issueId) => (
|
||||
<IssueKanBanBlock key={issueId} anchor={anchor} issueId={issueId} />
|
||||
))}
|
||||
{shouldLoadMore && (
|
||||
<div className="w-full h-[100px] bg-custom-background-80 animate-pulse" ref={setIntersectionElement} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2 pt-10 text-center text-sm font-medium text-custom-text-200">
|
||||
<Icon iconName="stack" />
|
||||
No issues in this state
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
130
space/core/components/issues/issue-layouts/kanban/default.tsx
Normal file
130
space/core/components/issues/issue-layouts/kanban/default.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { MutableRefObject } from "react";
|
||||
import isNil from "lodash/isNil";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import {
|
||||
GroupByColumnTypes,
|
||||
IGroupByColumn,
|
||||
TGroupedIssues,
|
||||
IIssueDisplayProperties,
|
||||
TSubGroupedIssues,
|
||||
TIssueGroupByOptions,
|
||||
TPaginationData,
|
||||
TLoader,
|
||||
} from "@plane/types";
|
||||
// hooks
|
||||
import { useMember, useModule, useStates, useLabel, useCycle } from "@/hooks/store";
|
||||
//
|
||||
import { getGroupByColumns } from "../utils";
|
||||
// components
|
||||
import { HeaderGroupByCard } from "./headers/group-by-card";
|
||||
import { KanbanGroup } from "./kanban-group";
|
||||
|
||||
export interface IKanBan {
|
||||
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
subGroupBy: TIssueGroupByOptions | undefined;
|
||||
groupBy: TIssueGroupByOptions | undefined;
|
||||
subGroupId?: string;
|
||||
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
showEmptyGroup?: boolean;
|
||||
}
|
||||
|
||||
export const KanBan: React.FC<IKanBan> = observer((props) => {
|
||||
const {
|
||||
groupedIssueIds,
|
||||
displayProperties,
|
||||
subGroupBy,
|
||||
groupBy,
|
||||
subGroupId = "null",
|
||||
loadMoreIssues,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
getIssueLoader,
|
||||
scrollableContainerRef,
|
||||
showEmptyGroup = true,
|
||||
} = props;
|
||||
|
||||
const member = useMember();
|
||||
const label = useLabel();
|
||||
const cycle = useCycle();
|
||||
const modules = useModule();
|
||||
const state = useStates();
|
||||
|
||||
const groupList = getGroupByColumns(groupBy as GroupByColumnTypes, cycle, modules, label, state, member);
|
||||
|
||||
if (!groupList) return null;
|
||||
|
||||
const visibilityGroupBy = (_list: IGroupByColumn): { showGroup: boolean; showIssues: boolean } => {
|
||||
if (subGroupBy) {
|
||||
const groupVisibility = {
|
||||
showGroup: true,
|
||||
showIssues: true,
|
||||
};
|
||||
if (!showEmptyGroup) {
|
||||
groupVisibility.showGroup = (getGroupIssueCount(_list.id, undefined, false) ?? 0) > 0;
|
||||
}
|
||||
return groupVisibility;
|
||||
} else {
|
||||
const groupVisibility = {
|
||||
showGroup: true,
|
||||
showIssues: true,
|
||||
};
|
||||
return groupVisibility;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative w-full flex gap-2 px-2 ${subGroupBy ? "h-full" : "h-full"}`}>
|
||||
{groupList &&
|
||||
groupList.length > 0 &&
|
||||
groupList.map((subList: IGroupByColumn) => {
|
||||
const groupByVisibilityToggle = visibilityGroupBy(subList);
|
||||
|
||||
if (groupByVisibilityToggle.showGroup === false) return <></>;
|
||||
return (
|
||||
<div
|
||||
key={subList.id}
|
||||
className={`group relative flex flex-shrink-0 flex-col ${
|
||||
groupByVisibilityToggle.showIssues ? `w-[350px]` : ``
|
||||
} `}
|
||||
>
|
||||
{isNil(subGroupBy) && (
|
||||
<div className="sticky top-0 z-[2] w-full flex-shrink-0 bg-custom-background-90 py-1">
|
||||
<HeaderGroupByCard
|
||||
groupBy={groupBy}
|
||||
icon={subList.icon}
|
||||
title={subList.name}
|
||||
count={getGroupIssueCount(subList.id, undefined, false) ?? 0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupByVisibilityToggle.showIssues && (
|
||||
<KanbanGroup
|
||||
groupId={subList.id}
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
displayProperties={displayProperties}
|
||||
subGroupBy={subGroupBy}
|
||||
subGroupId={subGroupId}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
getPaginationData={getPaginationData}
|
||||
getIssueLoader={getIssueLoader}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { StateGroupIcon } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssue, useStates } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
stateId: string;
|
||||
};
|
||||
|
||||
export const IssueKanBanHeader: React.FC<Props> = observer((props) => {
|
||||
const { stateId } = props;
|
||||
|
||||
const { getStateById } = useStates();
|
||||
const { getGroupIssueCount } = useIssue();
|
||||
|
||||
const state = getStateById(stateId);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 pb-2">
|
||||
<div className="flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
||||
<StateGroupIcon stateGroup={state?.group ?? "backlog"} color={state?.color} height="14" width="14" />
|
||||
</div>
|
||||
<div className="mr-1 truncate font-medium capitalize text-custom-text-200">{state?.name ?? "State"}</div>
|
||||
<span className="flex-shrink-0 rounded-full text-custom-text-300">
|
||||
{getGroupIssueCount(stateId, undefined, false) ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
"use client";
|
||||
|
||||
import React, { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Circle } from "lucide-react";
|
||||
// types
|
||||
import { TIssueGroupByOptions } from "@plane/types";
|
||||
|
||||
interface IHeaderGroupByCard {
|
||||
groupBy: TIssueGroupByOptions | undefined;
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
|
||||
const { icon, title, count } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`relative flex flex-shrink-0 gap-2 p-1.5 w-full flex-row items-center`}>
|
||||
<div className="flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm">
|
||||
{icon ? icon : <Circle width={14} strokeWidth={2} />}
|
||||
</div>
|
||||
|
||||
<div className={`relative flex items-center gap-1 w-full flex-row overflow-hidden`}>
|
||||
<div className={`line-clamp-1 inline-block overflow-hidden truncate font-medium text-custom-text-100`}>
|
||||
{title}
|
||||
</div>
|
||||
<div className={`flex-shrink-0 text-sm font-medium text-custom-text-300 pl-2`}>{count || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import React, { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Circle, ChevronDown, ChevronUp } from "lucide-react";
|
||||
// mobx
|
||||
|
||||
interface IHeaderSubGroupByCard {
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
count: number;
|
||||
isExpanded: boolean;
|
||||
toggleExpanded: () => void;
|
||||
}
|
||||
|
||||
export const HeaderSubGroupByCard: FC<IHeaderSubGroupByCard> = observer((props) => {
|
||||
const { icon, title, count, isExpanded, toggleExpanded } = props;
|
||||
return (
|
||||
<div
|
||||
className={`relative flex w-full flex-shrink-0 flex-row items-center gap-2 rounded-sm p-1.5 cursor-pointer`}
|
||||
onClick={() => toggleExpanded()}
|
||||
>
|
||||
<div className="flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm transition-all hover:bg-custom-background-80">
|
||||
{isExpanded ? <ChevronUp width={14} strokeWidth={2} /> : <ChevronDown width={14} strokeWidth={2} />}
|
||||
</div>
|
||||
|
||||
<div className="flex h-[20px] w-[20px] flex-shrink-0 items-center justify-center overflow-hidden rounded-sm">
|
||||
{icon ? icon : <Circle width={14} strokeWidth={2} />}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-1 text-sm">
|
||||
<div className="line-clamp-1 text-custom-text-100">{title}</div>
|
||||
<div className="pl-2 text-sm font-medium text-custom-text-300">{count || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,3 +1,2 @@
|
|||
export * from "./block";
|
||||
export * from "./header";
|
||||
export * from "./root";
|
||||
export * from "./blocks-list";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
"use client";
|
||||
|
||||
import { MutableRefObject, forwardRef, useCallback, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
//types
|
||||
import {
|
||||
TGroupedIssues,
|
||||
IIssueDisplayProperties,
|
||||
TSubGroupedIssues,
|
||||
TIssueGroupByOptions,
|
||||
TPaginationData,
|
||||
TLoader,
|
||||
} from "@plane/types";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
|
||||
//
|
||||
import { KanbanIssueBlocksList } from ".";
|
||||
|
||||
interface IKanbanGroup {
|
||||
groupId: string;
|
||||
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
subGroupBy: TIssueGroupByOptions | undefined;
|
||||
subGroupId: string;
|
||||
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
// Loader components
|
||||
const KanbanIssueBlockLoader = forwardRef<HTMLSpanElement>((props, ref) => (
|
||||
<span ref={ref} className="block h-28 m-1.5 animate-pulse bg-custom-background-80 rounded" />
|
||||
));
|
||||
KanbanIssueBlockLoader.displayName = "KanbanIssueBlockLoader";
|
||||
|
||||
export const KanbanGroup = observer((props: IKanbanGroup) => {
|
||||
const {
|
||||
groupId,
|
||||
subGroupId,
|
||||
subGroupBy,
|
||||
displayProperties,
|
||||
groupedIssueIds,
|
||||
loadMoreIssues,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
getIssueLoader,
|
||||
scrollableContainerRef,
|
||||
} = props;
|
||||
|
||||
// hooks
|
||||
const [intersectionElement, setIntersectionElement] = useState<HTMLSpanElement | null>(null);
|
||||
const columnRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const containerRef = subGroupBy && scrollableContainerRef ? scrollableContainerRef : columnRef;
|
||||
|
||||
const loadMoreIssuesInThisGroup = useCallback(() => {
|
||||
loadMoreIssues(groupId, subGroupId === "null" ? undefined : subGroupId);
|
||||
}, [loadMoreIssues, groupId, subGroupId]);
|
||||
|
||||
const isPaginating = !!getIssueLoader(groupId, subGroupId);
|
||||
|
||||
useIntersectionObserver(
|
||||
containerRef,
|
||||
isPaginating ? null : intersectionElement,
|
||||
loadMoreIssuesInThisGroup,
|
||||
`0% 100% 100% 100%`
|
||||
);
|
||||
|
||||
const isSubGroup = !!subGroupId && subGroupId !== "null";
|
||||
|
||||
const issueIds = isSubGroup
|
||||
? (groupedIssueIds as TSubGroupedIssues)?.[groupId]?.[subGroupId] ?? []
|
||||
: (groupedIssueIds as TGroupedIssues)?.[groupId] ?? [];
|
||||
|
||||
const groupIssueCount = getGroupIssueCount(groupId, subGroupId, false) ?? 0;
|
||||
const nextPageResults = getPaginationData(groupId, subGroupId)?.nextPageResults;
|
||||
|
||||
const loadMore = isPaginating ? (
|
||||
<KanbanIssueBlockLoader />
|
||||
) : (
|
||||
<div
|
||||
className="w-full p-3 text-sm font-medium text-custom-primary-100 hover:text-custom-primary-200 hover:underline cursor-pointer"
|
||||
onClick={loadMoreIssuesInThisGroup}
|
||||
>
|
||||
{" "}
|
||||
Load More ↓
|
||||
</div>
|
||||
);
|
||||
|
||||
const shouldLoadMore = nextPageResults === undefined ? issueIds?.length < groupIssueCount : !!nextPageResults;
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`${groupId}__${subGroupId}`}
|
||||
className={cn("relative h-full transition-all min-h-[120px]", { "vertical-scrollbar scrollbar-md": !subGroupBy })}
|
||||
ref={columnRef}
|
||||
>
|
||||
<KanbanIssueBlocksList
|
||||
subGroupId={subGroupId}
|
||||
groupId={groupId}
|
||||
issueIds={issueIds || []}
|
||||
displayProperties={displayProperties}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
/>
|
||||
|
||||
{shouldLoadMore && (isSubGroup ? <>{loadMore}</> : <KanbanIssueBlockLoader ref={setIntersectionElement} />)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// mobx hook
|
||||
import { TGroupedIssues } from "@plane/types";
|
||||
// hooks
|
||||
import { useIssue } from "@/hooks/store";
|
||||
import { Column } from "./column";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
};
|
||||
|
||||
export const IssueKanbanLayoutRoot: FC<Props> = observer((props) => {
|
||||
const { anchor } = props;
|
||||
// store hooks
|
||||
const { groupedIssueIds } = useIssue();
|
||||
|
||||
const groupedIssues = groupedIssueIds as TGroupedIssues | undefined;
|
||||
|
||||
if (!groupedIssues) return <></>;
|
||||
|
||||
const issueGroupIds = Object.keys(groupedIssues);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full gap-3 overflow-hidden overflow-x-auto">
|
||||
{issueGroupIds?.map((stateId) => {
|
||||
const issueIds = groupedIssues[stateId];
|
||||
return <Column key={stateId} anchor={anchor} stateId={stateId} issueIds={issueIds} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
294
space/core/components/issues/issue-layouts/kanban/swimlanes.tsx
Normal file
294
space/core/components/issues/issue-layouts/kanban/swimlanes.tsx
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import { MutableRefObject, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import {
|
||||
GroupByColumnTypes,
|
||||
IGroupByColumn,
|
||||
TGroupedIssues,
|
||||
IIssueDisplayProperties,
|
||||
TSubGroupedIssues,
|
||||
TIssueGroupByOptions,
|
||||
TIssueOrderByOptions,
|
||||
TPaginationData,
|
||||
TLoader,
|
||||
} from "@plane/types";
|
||||
// hooks
|
||||
import { useMember, useModule, useStates, useLabel, useCycle } from "@/hooks/store";
|
||||
//
|
||||
import { getGroupByColumns } from "../utils";
|
||||
import { KanBan } from "./default";
|
||||
import { HeaderGroupByCard } from "./headers/group-by-card";
|
||||
import { HeaderSubGroupByCard } from "./headers/sub-group-by-card";
|
||||
|
||||
export interface IKanBanSwimLanes {
|
||||
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
subGroupBy: TIssueGroupByOptions | undefined;
|
||||
groupBy: TIssueGroupByOptions | undefined;
|
||||
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
|
||||
showEmptyGroup: boolean;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
orderBy: TIssueOrderByOptions | undefined;
|
||||
}
|
||||
|
||||
export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
||||
const {
|
||||
groupedIssueIds,
|
||||
displayProperties,
|
||||
subGroupBy,
|
||||
groupBy,
|
||||
orderBy,
|
||||
loadMoreIssues,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
getIssueLoader,
|
||||
showEmptyGroup,
|
||||
scrollableContainerRef,
|
||||
} = props;
|
||||
|
||||
const member = useMember();
|
||||
const label = useLabel();
|
||||
const cycle = useCycle();
|
||||
const modules = useModule();
|
||||
const state = useStates();
|
||||
|
||||
const groupByList = getGroupByColumns(groupBy as GroupByColumnTypes, cycle, modules, label, state, member);
|
||||
const subGroupByList = getGroupByColumns(subGroupBy as GroupByColumnTypes, cycle, modules, label, state, member);
|
||||
|
||||
if (!groupByList || !subGroupByList) return null;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="sticky top-0 z-[4] h-[50px] bg-custom-background-90 px-2">
|
||||
<SubGroupSwimlaneHeader
|
||||
groupBy={groupBy}
|
||||
subGroupBy={subGroupBy}
|
||||
groupList={groupByList}
|
||||
showEmptyGroup={showEmptyGroup}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{subGroupBy && (
|
||||
<SubGroupSwimlane
|
||||
groupList={subGroupByList}
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
displayProperties={displayProperties}
|
||||
groupBy={groupBy}
|
||||
subGroupBy={subGroupBy}
|
||||
orderBy={orderBy}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
getPaginationData={getPaginationData}
|
||||
getIssueLoader={getIssueLoader}
|
||||
showEmptyGroup={showEmptyGroup}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface ISubGroupSwimlaneHeader {
|
||||
subGroupBy: TIssueGroupByOptions | undefined;
|
||||
groupBy: TIssueGroupByOptions | undefined;
|
||||
groupList: IGroupByColumn[];
|
||||
showEmptyGroup: boolean;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
}
|
||||
|
||||
const visibilitySubGroupByGroupCount = (subGroupIssueCount: number, showEmptyGroup: boolean): boolean => {
|
||||
let subGroupHeaderVisibility = true;
|
||||
|
||||
if (showEmptyGroup) subGroupHeaderVisibility = true;
|
||||
else {
|
||||
if (subGroupIssueCount > 0) subGroupHeaderVisibility = true;
|
||||
else subGroupHeaderVisibility = false;
|
||||
}
|
||||
|
||||
return subGroupHeaderVisibility;
|
||||
};
|
||||
|
||||
const SubGroupSwimlaneHeader: React.FC<ISubGroupSwimlaneHeader> = observer(
|
||||
({ subGroupBy, groupBy, groupList, showEmptyGroup, getGroupIssueCount }) => (
|
||||
<div className="relative flex h-max min-h-full w-full items-center gap-2">
|
||||
{groupList &&
|
||||
groupList.length > 0 &&
|
||||
groupList.map((group: IGroupByColumn) => {
|
||||
const groupCount = getGroupIssueCount(group.id, undefined, false) ?? 0;
|
||||
|
||||
const subGroupByVisibilityToggle = visibilitySubGroupByGroupCount(groupCount, showEmptyGroup);
|
||||
|
||||
if (subGroupByVisibilityToggle === false) return <></>;
|
||||
|
||||
return (
|
||||
<div key={`${subGroupBy}_${group.id}`} className="flex w-[350px] flex-shrink-0 flex-col">
|
||||
<HeaderGroupByCard groupBy={groupBy} icon={group.icon} title={group.name} count={groupCount} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader {
|
||||
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
|
||||
showEmptyGroup: boolean;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
orderBy: TIssueOrderByOptions | undefined;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
|
||||
}
|
||||
|
||||
const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
||||
const {
|
||||
groupedIssueIds,
|
||||
subGroupBy,
|
||||
groupBy,
|
||||
groupList,
|
||||
displayProperties,
|
||||
loadMoreIssues,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
getIssueLoader,
|
||||
showEmptyGroup,
|
||||
scrollableContainerRef,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="relative h-max min-h-full w-full">
|
||||
{groupList &&
|
||||
groupList.length > 0 &&
|
||||
groupList.map((group: IGroupByColumn) => (
|
||||
<SubGroup
|
||||
key={group.id}
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
subGroupBy={subGroupBy}
|
||||
groupBy={groupBy}
|
||||
group={group}
|
||||
displayProperties={displayProperties}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
getPaginationData={getPaginationData}
|
||||
getIssueLoader={getIssueLoader}
|
||||
showEmptyGroup={showEmptyGroup}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface ISubGroup {
|
||||
groupedIssueIds: TGroupedIssues | TSubGroupedIssues;
|
||||
showEmptyGroup: boolean;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
groupBy: TIssueGroupByOptions | undefined;
|
||||
subGroupBy: TIssueGroupByOptions | undefined;
|
||||
group: IGroupByColumn;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
loadMoreIssues: (groupId?: string, subGroupId?: string) => void;
|
||||
}
|
||||
|
||||
const SubGroup: React.FC<ISubGroup> = observer((props) => {
|
||||
const {
|
||||
groupedIssueIds,
|
||||
subGroupBy,
|
||||
groupBy,
|
||||
group,
|
||||
displayProperties,
|
||||
loadMoreIssues,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
getIssueLoader,
|
||||
showEmptyGroup,
|
||||
scrollableContainerRef,
|
||||
} = props;
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
const toggleExpanded = () => {
|
||||
setIsExpanded((prevState) => !prevState);
|
||||
};
|
||||
|
||||
const visibilitySubGroupBy = (
|
||||
_list: IGroupByColumn,
|
||||
subGroupCount: number
|
||||
): { showGroup: boolean; showIssues: boolean } => {
|
||||
const subGroupVisibility = {
|
||||
showGroup: true,
|
||||
showIssues: true,
|
||||
};
|
||||
if (showEmptyGroup) subGroupVisibility.showGroup = true;
|
||||
else {
|
||||
if (subGroupCount > 0) subGroupVisibility.showGroup = true;
|
||||
else subGroupVisibility.showGroup = false;
|
||||
}
|
||||
return subGroupVisibility;
|
||||
};
|
||||
|
||||
const issueCount = getGroupIssueCount(undefined, group.id, true) ?? 0;
|
||||
const subGroupByVisibilityToggle = visibilitySubGroupBy(group, issueCount);
|
||||
if (subGroupByVisibilityToggle.showGroup === false) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-shrink-0 flex-col">
|
||||
<div className="sticky top-[50px] z-[3] py-1 flex w-full items-center bg-custom-background-100 border-y-[0.5px] border-custom-border-200">
|
||||
<div className="sticky left-0 flex-shrink-0">
|
||||
<HeaderSubGroupByCard
|
||||
icon={group.icon}
|
||||
title={group.name || ""}
|
||||
count={issueCount}
|
||||
isExpanded={isExpanded}
|
||||
toggleExpanded={toggleExpanded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{subGroupByVisibilityToggle.showIssues && isExpanded && (
|
||||
<div className="relative">
|
||||
<KanBan
|
||||
groupedIssueIds={groupedIssueIds}
|
||||
displayProperties={displayProperties}
|
||||
subGroupBy={subGroupBy}
|
||||
groupBy={groupBy}
|
||||
subGroupId={group.id}
|
||||
showEmptyGroup={showEmptyGroup}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
getPaginationData={getPaginationData}
|
||||
getIssueLoader={getIssueLoader}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { useCallback, useMemo } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import { IIssueDisplayProperties, TGroupedIssues } from "@plane/types";
|
||||
// constants
|
||||
// components
|
||||
import { IssueLayoutHOC } from "@/components/issues/issue-layouts/issue-layout-HOC";
|
||||
// hooks
|
||||
import { useIssue } from "@/hooks/store";
|
||||
import { List } from "./default";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
};
|
||||
|
||||
export const IssuesListLayoutRoot = observer((props: Props) => {
|
||||
const { anchor } = props;
|
||||
// store hooks
|
||||
const {
|
||||
groupedIssueIds: storeGroupedIssueIds,
|
||||
fetchNextPublicIssues,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
getIssueLoader,
|
||||
} = useIssue();
|
||||
|
||||
const groupedIssueIds = storeGroupedIssueIds as TGroupedIssues | undefined;
|
||||
// auth
|
||||
const displayProperties: IIssueDisplayProperties = useMemo(
|
||||
() => ({
|
||||
key: true,
|
||||
state: true,
|
||||
labels: true,
|
||||
priority: true,
|
||||
due_date: true,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const loadMoreIssues = useCallback(
|
||||
(groupId?: string) => {
|
||||
fetchNextPublicIssues(anchor, groupId);
|
||||
},
|
||||
[fetchNextPublicIssues]
|
||||
);
|
||||
|
||||
return (
|
||||
<IssueLayoutHOC getGroupIssueCount={getGroupIssueCount} getIssueLoader={getIssueLoader}>
|
||||
<div className={`relative size-full bg-custom-background-90`}>
|
||||
<List
|
||||
displayProperties={displayProperties}
|
||||
groupBy={"state"}
|
||||
groupedIssueIds={groupedIssueIds ?? {}}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
getPaginationData={getPaginationData}
|
||||
getIssueLoader={getIssueLoader}
|
||||
showEmptyGroup
|
||||
/>
|
||||
</div>
|
||||
</IssueLayoutHOC>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,88 +1,90 @@
|
|||
"use client";
|
||||
import { FC } from "react";
|
||||
|
||||
import { useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
// components
|
||||
import { IssueBlockDueDate, IssueBlockLabels, IssueBlockPriority, IssueBlockState } from "@/components/issues";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
// types
|
||||
import { cn } from "@plane/editor";
|
||||
import { IIssueDisplayProperties } from "@plane/types";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// helpers
|
||||
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||
// hook
|
||||
import { useIssue, useIssueDetails, usePublish } from "@/hooks/store";
|
||||
// hooks
|
||||
import { useIssueDetails, usePublish } from "@/hooks/store";
|
||||
//
|
||||
import { IssueProperties } from "../properties/all-properties";
|
||||
|
||||
type IssueListBlockProps = {
|
||||
anchor: string;
|
||||
interface IssueBlockProps {
|
||||
issueId: string;
|
||||
};
|
||||
groupId: string;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
}
|
||||
|
||||
export const IssueListLayoutBlock: FC<IssueListBlockProps> = observer((props) => {
|
||||
const { anchor, issueId } = props;
|
||||
const { getIssueById } = useIssue();
|
||||
// query params
|
||||
export const IssueBlock = observer((props: IssueBlockProps) => {
|
||||
const { anchor } = useParams();
|
||||
const { issueId, displayProperties } = props;
|
||||
const searchParams = useSearchParams();
|
||||
const board = searchParams.get("board") || undefined;
|
||||
const state = searchParams.get("state") || undefined;
|
||||
const priority = searchParams.get("priority") || undefined;
|
||||
const labels = searchParams.get("labels") || undefined;
|
||||
// store hooks
|
||||
const { setPeekId } = useIssueDetails();
|
||||
const { project_details } = usePublish(anchor);
|
||||
// query params
|
||||
const board = searchParams.get("board");
|
||||
// ref
|
||||
const issueRef = useRef<HTMLDivElement | null>(null);
|
||||
// hooks
|
||||
const { project_details } = usePublish(anchor.toString());
|
||||
const { getIsIssuePeeked, setPeekId, getIssueById } = useIssueDetails();
|
||||
|
||||
const { queryParam } = queryParamGenerator({ board, peekId: issueId, priority, state, labels });
|
||||
const handleBlockClick = () => {
|
||||
const handleIssuePeekOverview = () => {
|
||||
setPeekId(issueId);
|
||||
};
|
||||
|
||||
const { queryParam } = queryParamGenerator(board ? { board, peekId: issueId } : { peekId: issueId });
|
||||
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue) return <></>;
|
||||
if (!issue) return null;
|
||||
|
||||
const projectIdentifier = project_details?.identifier;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/issues/${anchor}?${queryParam}`}
|
||||
onClick={handleBlockClick}
|
||||
className="relative flex items-center gap-10 bg-custom-background-100 p-3"
|
||||
<div
|
||||
ref={issueRef}
|
||||
className={cn(
|
||||
"group/list-block min-h-11 relative flex flex-col md:flex-row md:items-center gap-3 bg-custom-background-100 hover:bg-custom-background-90 p-3 pl-1.5 text-sm transition-colors border-b border-b-custom-border-200",
|
||||
{
|
||||
"border-custom-primary-70": getIsIssuePeeked(issue.id),
|
||||
"last:border-b-transparent": !getIsIssuePeeked(issue.id),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="relative flex w-full flex-grow items-center gap-3 overflow-hidden">
|
||||
{/* id */}
|
||||
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300">
|
||||
{project_details?.identifier}-{issue?.sequence_id}
|
||||
</div>
|
||||
{/* name */}
|
||||
<div onClick={handleBlockClick} className="flex-grow cursor-pointer truncate text-sm">
|
||||
{issue.name}
|
||||
<div className="flex w-full truncate">
|
||||
<div className="flex flex-grow items-center gap-0.5 truncate">
|
||||
<div className="flex items-center gap-1">
|
||||
{displayProperties && displayProperties?.key && (
|
||||
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300 px-4">
|
||||
{projectIdentifier}-{issue.sequence_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
id={`issue-${issue.id}`}
|
||||
href={`?${queryParam}`}
|
||||
onClick={handleIssuePeekOverview}
|
||||
className="w-full truncate cursor-pointer text-sm text-custom-text-100"
|
||||
>
|
||||
<Tooltip tooltipContent={issue.name} position="top-left">
|
||||
<p className="truncate">{issue.name}</p>
|
||||
</Tooltip>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="inline-flex flex-shrink-0 items-center gap-2 text-xs">
|
||||
{/* priority */}
|
||||
{issue?.priority && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockPriority priority={issue?.priority} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* state */}
|
||||
{issue?.state_id && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockState stateId={issue?.state_id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* labels */}
|
||||
{issue?.label_ids && issue?.label_ids.length > 0 && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockLabels labelIds={issue?.label_ids} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* due date */}
|
||||
{issue?.target_date && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockDueDate due_date={issue?.target_date} stateId={issue?.state_id ?? undefined} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<IssueProperties
|
||||
className="relative flex flex-wrap md:flex-grow md:flex-shrink-0 items-center gap-2 whitespace-nowrap"
|
||||
issue={issue}
|
||||
displayProperties={displayProperties}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import { FC, MutableRefObject } from "react";
|
||||
// types
|
||||
import { IIssueDisplayProperties } from "@plane/types";
|
||||
import { IssueBlock } from "./block";
|
||||
|
||||
interface Props {
|
||||
issueIds: string[] | undefined;
|
||||
groupId: string;
|
||||
displayProperties?: IIssueDisplayProperties;
|
||||
containerRef: MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const IssueBlocksList: FC<Props> = (props) => {
|
||||
const { issueIds = [], groupId, displayProperties } = props;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{issueIds &&
|
||||
issueIds?.length > 0 &&
|
||||
issueIds.map((issueId: string) => (
|
||||
<IssueBlock key={issueId} issueId={issueId} displayProperties={displayProperties} groupId={groupId} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
86
space/core/components/issues/issue-layouts/list/default.tsx
Normal file
86
space/core/components/issues/issue-layouts/list/default.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import {
|
||||
GroupByColumnTypes,
|
||||
TGroupedIssues,
|
||||
IIssueDisplayProperties,
|
||||
TIssueGroupByOptions,
|
||||
IGroupByColumn,
|
||||
TPaginationData,
|
||||
TLoader,
|
||||
} from "@plane/types";
|
||||
// hooks
|
||||
import { useMember, useModule, useStates, useLabel, useCycle } from "@/hooks/store";
|
||||
//
|
||||
import { getGroupByColumns } from "../utils";
|
||||
import { ListGroup } from "./list-group";
|
||||
|
||||
export interface IList {
|
||||
groupedIssueIds: TGroupedIssues;
|
||||
groupBy: TIssueGroupByOptions | undefined;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
showEmptyGroup?: boolean;
|
||||
loadMoreIssues: (groupId?: string) => void;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
|
||||
}
|
||||
|
||||
export const List: React.FC<IList> = observer((props) => {
|
||||
const {
|
||||
groupedIssueIds,
|
||||
groupBy,
|
||||
displayProperties,
|
||||
showEmptyGroup,
|
||||
loadMoreIssues,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
getIssueLoader,
|
||||
} = props;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const member = useMember();
|
||||
const label = useLabel();
|
||||
const cycle = useCycle();
|
||||
const modules = useModule();
|
||||
const state = useStates();
|
||||
|
||||
const groupList = getGroupByColumns(groupBy as GroupByColumnTypes, cycle, modules, label, state, member, true);
|
||||
|
||||
if (!groupList) return null;
|
||||
|
||||
return (
|
||||
<div className="relative size-full flex flex-col">
|
||||
{groupList && (
|
||||
<>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="size-full vertical-scrollbar scrollbar-lg relative overflow-auto vertical-scrollbar-margin-top-md"
|
||||
>
|
||||
{groupList.map((group: IGroupByColumn) => (
|
||||
<ListGroup
|
||||
key={group.id}
|
||||
groupIssueIds={groupedIssueIds?.[group.id]}
|
||||
groupBy={groupBy}
|
||||
group={group}
|
||||
displayProperties={displayProperties}
|
||||
showEmptyGroup={showEmptyGroup}
|
||||
loadMoreIssues={loadMoreIssues}
|
||||
getGroupIssueCount={getGroupIssueCount}
|
||||
getPaginationData={getPaginationData}
|
||||
getIssueLoader={getIssueLoader}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import { useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useIssue } from "@/hooks/store";
|
||||
// components
|
||||
import { IssueListLayoutBlock } from "./block";
|
||||
import { IssueListLayoutHeader } from "./header";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
stateId: string;
|
||||
issueIds: string[];
|
||||
};
|
||||
|
||||
export const Group = observer((props: Props) => {
|
||||
const { anchor, stateId, issueIds } = props;
|
||||
|
||||
const { fetchNextPublicIssues, getPaginationData, getIssueLoader, getGroupIssueCount } = useIssue();
|
||||
|
||||
const loadMoreIssuesInThisGroup = useCallback(() => {
|
||||
fetchNextPublicIssues(anchor, stateId);
|
||||
}, [stateId]);
|
||||
|
||||
const isPaginating = !!getIssueLoader(stateId);
|
||||
const nextPageResults = getPaginationData(stateId, undefined)?.nextPageResults;
|
||||
|
||||
const groupIssueCount = getGroupIssueCount(stateId, undefined, false);
|
||||
const shouldLoadMore =
|
||||
nextPageResults === undefined && groupIssueCount !== undefined
|
||||
? issueIds?.length < groupIssueCount
|
||||
: !!nextPageResults;
|
||||
|
||||
return (
|
||||
<div key={stateId} className="relative w-full">
|
||||
<IssueListLayoutHeader stateId={stateId} />
|
||||
{issueIds && issueIds.length > 0 ? (
|
||||
<div className="divide-y divide-custom-border-200">
|
||||
{issueIds.map((issueId) => (
|
||||
<IssueListLayoutBlock key={issueId} anchor={anchor} issueId={issueId} />
|
||||
))}
|
||||
{isPaginating ? (
|
||||
<div className="w-full h-[46px] bg-custom-background-80 animate-pulse" />
|
||||
) : (
|
||||
shouldLoadMore && (
|
||||
<div
|
||||
className="w-full min-h-[45px] bg-custom-background-100 p-3 text-sm border-b-[1px] cursor-pointer text-custom-text-350 hover:text-custom-text-300"
|
||||
onClick={loadMoreIssuesInThisGroup}
|
||||
>
|
||||
Load More ↓
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-custom-background-100 p-3 text-sm text-custom-text-400">No issues.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { StateGroupIcon } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssue, useStates } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
stateId: string;
|
||||
};
|
||||
|
||||
export const IssueListLayoutHeader: React.FC<Props> = observer((props) => {
|
||||
const { stateId } = props;
|
||||
|
||||
const { getStateById } = useStates();
|
||||
const { getGroupIssueCount } = useIssue();
|
||||
|
||||
const state = getStateById(stateId);
|
||||
|
||||
return (
|
||||
<div className="flex sticky top-0 items-center gap-2 p-3 bg-custom-background-90 z-[1] border-b-[1px] border-custom-border-200">
|
||||
<div className="flex h-3.5 w-3.5 items-center justify-center">
|
||||
<StateGroupIcon stateGroup={state?.group ?? "backlog"} color={state?.color} height="14" width="14" />
|
||||
</div>
|
||||
<div className="mr-1 font-medium capitalize">{state?.name}</div>
|
||||
<div className="text-sm font-medium text-custom-text-200">
|
||||
{getGroupIssueCount(stateId, undefined, false) ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { CircleDashed } from "lucide-react";
|
||||
|
||||
interface IHeaderGroupByCard {
|
||||
groupID: string;
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
count: number;
|
||||
toggleListGroup: (id: string) => void;
|
||||
}
|
||||
|
||||
export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => {
|
||||
const { groupID, icon, title, count, toggleListGroup } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="group/list-header relative w-full flex-shrink-0 flex items-center gap-2 py-1.5"
|
||||
onClick={() => toggleListGroup(groupID)}
|
||||
>
|
||||
<div className="flex-shrink-0 grid place-items-center overflow-hidden">
|
||||
{icon ?? <CircleDashed className="size-3.5" strokeWidth={2} />}
|
||||
</div>
|
||||
|
||||
<div className="relative flex w-full flex-row items-center gap-1 overflow-hidden cursor-pointer">
|
||||
<div className="inline-block line-clamp-1 truncate font-medium text-custom-text-100">{title}</div>
|
||||
<div className="pl-2 text-sm font-medium text-custom-text-300">{count || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,3 +1,2 @@
|
|||
export * from "./block";
|
||||
export * from "./header";
|
||||
export * from "./root";
|
||||
export * from "./blocks-list";
|
||||
|
|
|
|||
129
space/core/components/issues/issue-layouts/list/list-group.tsx
Normal file
129
space/core/components/issues/issue-layouts/list/list-group.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"use client";
|
||||
|
||||
import { Fragment, MutableRefObject, forwardRef, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { cn } from "@plane/editor";
|
||||
// plane
|
||||
import { IGroupByColumn, TIssueGroupByOptions, IIssueDisplayProperties, TPaginationData, TLoader } from "@plane/types";
|
||||
// hooks
|
||||
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
|
||||
//
|
||||
import { IssueBlocksList } from "./blocks-list";
|
||||
import { HeaderGroupByCard } from "./headers/group-by-card";
|
||||
|
||||
interface Props {
|
||||
groupIssueIds: string[] | undefined;
|
||||
group: IGroupByColumn;
|
||||
groupBy: TIssueGroupByOptions | undefined;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
containerRef: MutableRefObject<HTMLDivElement | null>;
|
||||
showEmptyGroup?: boolean;
|
||||
loadMoreIssues: (groupId?: string) => void;
|
||||
getGroupIssueCount: (
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined,
|
||||
isSubGroupCumulative: boolean
|
||||
) => number | undefined;
|
||||
getPaginationData: (groupId: string | undefined, subGroupId: string | undefined) => TPaginationData | undefined;
|
||||
getIssueLoader: (groupId?: string | undefined, subGroupId?: string | undefined) => TLoader;
|
||||
}
|
||||
|
||||
// List loader component
|
||||
const ListLoaderItemRow = forwardRef<HTMLDivElement>((props, ref) => (
|
||||
<div ref={ref} className="flex items-center justify-between h-11 p-3 border-b border-custom-border-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="h-5 w-10 bg-custom-background-80 rounded animate-pulse" />
|
||||
<span className={`h-5 w-52 bg-custom-background-80 rounded animate-pulse`} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{[...Array(6)].map((_, index) => (
|
||||
<Fragment key={index}>
|
||||
<span key={index} className="h-5 w-5 bg-custom-background-80 rounded animate-pulse" />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
ListLoaderItemRow.displayName = "ListLoaderItemRow";
|
||||
|
||||
export const ListGroup = observer((props: Props) => {
|
||||
const {
|
||||
groupIssueIds = [],
|
||||
group,
|
||||
groupBy,
|
||||
displayProperties,
|
||||
containerRef,
|
||||
showEmptyGroup,
|
||||
loadMoreIssues,
|
||||
getGroupIssueCount,
|
||||
getPaginationData,
|
||||
getIssueLoader,
|
||||
} = props;
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const groupRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [intersectionElement, setIntersectionElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const groupIssueCount = getGroupIssueCount(group.id, undefined, false) ?? 0;
|
||||
const nextPageResults = getPaginationData(group.id, undefined)?.nextPageResults;
|
||||
const isPaginating = !!getIssueLoader(group.id);
|
||||
|
||||
useIntersectionObserver(containerRef, isPaginating ? null : intersectionElement, loadMoreIssues, `100% 0% 100% 0%`);
|
||||
|
||||
const shouldLoadMore =
|
||||
nextPageResults === undefined && groupIssueCount !== undefined && groupIssueIds
|
||||
? groupIssueIds.length < groupIssueCount
|
||||
: !!nextPageResults;
|
||||
|
||||
const loadMore = isPaginating ? (
|
||||
<ListLoaderItemRow />
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
"h-11 relative flex items-center gap-3 bg-custom-background-100 border border-transparent border-t-custom-border-200 pl-6 p-3 text-sm font-medium text-custom-primary-100 hover:text-custom-primary-200 hover:underline cursor-pointer"
|
||||
}
|
||||
onClick={() => loadMoreIssues(group.id)}
|
||||
>
|
||||
Load More ↓
|
||||
</div>
|
||||
);
|
||||
|
||||
const validateEmptyIssueGroups = (issueCount: number = 0) => {
|
||||
if (!showEmptyGroup && issueCount <= 0) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const toggleListGroup = () => {
|
||||
setIsExpanded((prevState) => !prevState);
|
||||
};
|
||||
|
||||
const shouldExpand = (!!groupIssueCount && isExpanded) || !groupBy;
|
||||
|
||||
return validateEmptyIssueGroups(groupIssueCount) ? (
|
||||
<div ref={groupRef} className={cn(`relative flex flex-shrink-0 flex-col border-[1px] border-transparent`)}>
|
||||
<div className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 pl-2 pr-3 py-1">
|
||||
<HeaderGroupByCard
|
||||
groupID={group.id}
|
||||
icon={group.icon}
|
||||
title={group.name || ""}
|
||||
count={groupIssueCount}
|
||||
toggleListGroup={toggleListGroup}
|
||||
/>
|
||||
</div>
|
||||
{shouldExpand && (
|
||||
<div className="relative">
|
||||
{groupIssueIds && (
|
||||
<IssueBlocksList
|
||||
issueIds={groupIssueIds}
|
||||
groupId={group.id}
|
||||
displayProperties={displayProperties}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldLoadMore && (groupBy ? <>{loadMore}</> : <ListLoaderItemRow ref={setIntersectionElement} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
});
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
"use client";
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import { TGroupedIssues } from "@plane/types";
|
||||
// mobx hook
|
||||
import { useIssue } from "@/hooks/store";
|
||||
import { Group } from "./group";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
};
|
||||
|
||||
export const IssuesListLayoutRoot: FC<Props> = observer((props) => {
|
||||
const { anchor } = props;
|
||||
// store hooks
|
||||
const { groupedIssueIds } = useIssue();
|
||||
|
||||
const groupedIssues = groupedIssueIds as TGroupedIssues | undefined;
|
||||
|
||||
if (!groupedIssues) return <></>;
|
||||
|
||||
const issueGroupIds = Object.keys(groupedIssues);
|
||||
|
||||
return (
|
||||
<>
|
||||
{issueGroupIds?.map((stateId) => {
|
||||
const issueIds = groupedIssues[stateId];
|
||||
|
||||
return <Group key={stateId} anchor={anchor} stateId={stateId} issueIds={issueIds} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { Layers, Link, Paperclip } from "lucide-react";
|
||||
// types
|
||||
import { cn } from "@plane/editor";
|
||||
import { IIssueDisplayProperties } from "@plane/types";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// ui
|
||||
// components
|
||||
import {
|
||||
IssueBlockDate,
|
||||
IssueBlockLabels,
|
||||
IssueBlockPriority,
|
||||
IssueBlockState,
|
||||
IssueBlockMembers,
|
||||
IssueBlockModules,
|
||||
IssueBlockCycle,
|
||||
} from "@/components/issues";
|
||||
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/with-display-properties-HOC";
|
||||
// helpers
|
||||
import { getDate } from "@/helpers/date-time.helper";
|
||||
//// hooks
|
||||
import { IIssue } from "@/types/issue";
|
||||
|
||||
export interface IIssueProperties {
|
||||
issue: IIssue;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
className: string;
|
||||
}
|
||||
|
||||
export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
const { issue, displayProperties, className } = props;
|
||||
|
||||
if (!displayProperties || !issue.project_id) return null;
|
||||
|
||||
const minDate = getDate(issue.start_date);
|
||||
minDate?.setDate(minDate.getDate());
|
||||
|
||||
const maxDate = getDate(issue.target_date);
|
||||
maxDate?.setDate(maxDate.getDate());
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* basic properties */}
|
||||
{/* state */}
|
||||
{issue.state_id && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="state">
|
||||
<div className="h-5">
|
||||
<IssueBlockState stateId={issue.state_id} />
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
)}
|
||||
|
||||
{/* priority */}
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="priority">
|
||||
<div className="h-5">
|
||||
<IssueBlockPriority priority={issue.priority} />
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* label */}
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="labels">
|
||||
<div className="h-5">
|
||||
<IssueBlockLabels labelIds={issue.label_ids} />
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* start date */}
|
||||
{issue?.start_date && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="start_date">
|
||||
<div className="h-5">
|
||||
<IssueBlockDate
|
||||
due_date={issue?.start_date}
|
||||
stateId={issue?.state_id ?? undefined}
|
||||
shouldHighLight={false}
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
)}
|
||||
|
||||
{/* target/due date */}
|
||||
{issue?.target_date && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="due_date">
|
||||
<div className="h-5">
|
||||
<IssueBlockDate due_date={issue?.target_date} stateId={issue?.state_id ?? undefined} />
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
)}
|
||||
|
||||
{/* assignee */}
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="assignee">
|
||||
<div className="h-5">
|
||||
<IssueBlockMembers memberIds={issue.assignee_ids} />
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* modules */}
|
||||
{issue.module_ids && issue.module_ids.length > 0 && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="modules">
|
||||
<div className="h-5">
|
||||
<IssueBlockModules moduleIds={issue.module_ids} />
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
)}
|
||||
|
||||
{/* cycles */}
|
||||
{issue.cycle_id && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="cycle">
|
||||
<div className="h-5">
|
||||
<IssueBlockCycle cycleId={issue.cycle_id} />
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
)}
|
||||
|
||||
{/* estimates */}
|
||||
{/* {projectId && areEstimateEnabledByProjectId(projectId?.toString()) && (
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="estimate">
|
||||
<div className="h-5">
|
||||
<EstimateDropdown
|
||||
value={issue.estimate_point ?? undefined}
|
||||
onChange={handleEstimate}
|
||||
projectId={issue.project_id}
|
||||
disabled={isReadOnly}
|
||||
buttonVariant="border-with-text"
|
||||
showTooltip
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
)} */}
|
||||
|
||||
{/* extra render properties */}
|
||||
{/* sub-issues */}
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey="sub_issue_count"
|
||||
shouldRenderProperty={(properties) => !!properties.sub_issue_count && !!issue.sub_issues_count}
|
||||
>
|
||||
<Tooltip tooltipHeading="Sub-issues" tooltipContent={`${issue.sub_issues_count}`}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1",
|
||||
{
|
||||
"hover:bg-custom-background-80 cursor-pointer": issue.sub_issues_count,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Layers className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
|
||||
<div className="text-xs">{issue.sub_issues_count}</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* attachments */}
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey="attachment_count"
|
||||
shouldRenderProperty={(properties) => !!properties.attachment_count && !!issue.attachment_count}
|
||||
>
|
||||
<Tooltip tooltipHeading="Attachments" tooltipContent={`${issue.attachment_count}`}>
|
||||
<div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1">
|
||||
<Paperclip className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
|
||||
<div className="text-xs">{issue.attachment_count}</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</WithDisplayPropertiesHOC>
|
||||
|
||||
{/* link */}
|
||||
<WithDisplayPropertiesHOC
|
||||
displayProperties={displayProperties}
|
||||
displayPropertyKey="link"
|
||||
shouldRenderProperty={(properties) => !!properties.link && !!issue.link_count}
|
||||
>
|
||||
<Tooltip tooltipHeading="Links" tooltipContent={`${issue.link_count}`}>
|
||||
<div className="flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1">
|
||||
<Link className="h-3 w-3 flex-shrink-0" strokeWidth={2} />
|
||||
<div className="text-xs">{issue.link_count}</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</WithDisplayPropertiesHOC>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { cn } from "@plane/editor";
|
||||
import { ContrastIcon, Tooltip } from "@plane/ui";
|
||||
//hooks
|
||||
import { useCycle } from "@/hooks/store/use-cycle";
|
||||
|
||||
type Props = {
|
||||
cycleId: string | undefined;
|
||||
shouldShowBorder?: boolean;
|
||||
};
|
||||
|
||||
export const IssueBlockCycle = observer(({ cycleId, shouldShowBorder = true }: Props) => {
|
||||
const { getCycleById } = useCycle();
|
||||
|
||||
const cycle = getCycleById(cycleId);
|
||||
|
||||
return (
|
||||
<Tooltip tooltipHeading="Cycle" tooltipContent={cycle?.name ?? "No Cycle"}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-between gap-1 rounded px-2.5 py-1 text-xs duration-300 focus:outline-none",
|
||||
{ "border-[0.5px] border-custom-border-300": shouldShowBorder }
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center text-xs gap-1.5">
|
||||
<ContrastIcon className="h-3 w-3 flex-shrink-0" />
|
||||
<div className="max-w-40 flex-grow truncate ">{cycle?.name ?? "No Cycle"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { observer } from "mobx-react";
|
||||
import { CalendarCheck2 } from "lucide-react";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
|
|
@ -10,27 +11,31 @@ import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
|
|||
import { useStates } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
due_date: string;
|
||||
due_date: string | undefined;
|
||||
stateId: string | undefined;
|
||||
shouldHighLight?: boolean;
|
||||
shouldShowBorder?: boolean;
|
||||
};
|
||||
|
||||
export const IssueBlockDueDate = observer((props: Props) => {
|
||||
const { due_date, stateId } = props;
|
||||
export const IssueBlockDate = observer((props: Props) => {
|
||||
const { due_date, stateId, shouldHighLight = true, shouldShowBorder = true } = props;
|
||||
const { getStateById } = useStates();
|
||||
|
||||
const state = getStateById(stateId);
|
||||
|
||||
const formattedDate = renderFormattedDate(due_date);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs text-custom-text-100",
|
||||
{
|
||||
"text-red-500": shouldHighlightIssueDueDate(due_date, state?.group),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<CalendarCheck2 className="size-3 flex-shrink-0" />
|
||||
{renderFormattedDate(due_date)}
|
||||
</div>
|
||||
<Tooltip tooltipHeading="Due Date" tooltipContent={formattedDate}>
|
||||
<div
|
||||
className={cn("flex h-full items-center gap-1 rounded px-2.5 py-1 text-xs text-custom-text-100", {
|
||||
"text-red-500": shouldHighLight && due_date && shouldHighlightIssueDueDate(due_date, state?.group),
|
||||
"border-[0.5px] border-custom-border-300": shouldShowBorder,
|
||||
})}
|
||||
>
|
||||
<CalendarCheck2 className="size-3 flex-shrink-0" />
|
||||
{formattedDate ? formattedDate : "No Date"}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,3 +2,7 @@ export * from "./due-date";
|
|||
export * from "./labels";
|
||||
export * from "./priority";
|
||||
export * from "./state";
|
||||
export * from "./cycle";
|
||||
export * from "./member";
|
||||
export * from "./modules";
|
||||
export * from "./all-properties";
|
||||
|
|
|
|||
|
|
@ -1,40 +1,69 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { Tags } from "lucide-react";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { useLabel } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
labelIds: string[];
|
||||
shouldShowLabel?: boolean;
|
||||
};
|
||||
|
||||
export const IssueBlockLabels = observer(({ labelIds }: Props) => {
|
||||
export const IssueBlockLabels = observer(({ labelIds, shouldShowLabel = false }: Props) => {
|
||||
const { getLabelsByIds } = useLabel();
|
||||
|
||||
const labels = getLabelsByIds(labelIds);
|
||||
|
||||
const labelsString = labels.map((label) => label.name).join(", ");
|
||||
const labelsString = labels.length > 0 ? labels.map((label) => label.name).join(", ") : "No Labels";
|
||||
|
||||
if (labels.length <= 0)
|
||||
return (
|
||||
<Tooltip position="top" tooltipHeading="Labels" tooltipContent="None">
|
||||
<div
|
||||
className={`flex h-full items-center justify-center gap-2 rounded px-2.5 py-1 text-xs border-[0.5px] border-custom-border-300`}
|
||||
>
|
||||
<Tags className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
{shouldShowLabel && <span>No Labels</span>}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-wrap items-center gap-1">
|
||||
{labels.length === 1 ? (
|
||||
<div
|
||||
key={labels[0].id}
|
||||
className="flex flex-shrink-0 cursor-default items-center rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: `${labels[0].color}` }} />
|
||||
<div className="text-xs">{labels[0].name}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-5 w-full flex-wrap items-center gap-2 overflow-hidden">
|
||||
{labels.length <= 2 ? (
|
||||
<>
|
||||
{labels.map((label) => (
|
||||
<Tooltip key={label.id} position="top" tooltipHeading="Labels" tooltipContent={label?.name ?? ""}>
|
||||
<div
|
||||
key={label?.id}
|
||||
className={`flex overflow-hidden h-full max-w-full flex-shrink-0 items-center rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs`}
|
||||
>
|
||||
<div className="flex max-w-full items-center gap-1.5 overflow-hidden text-custom-text-200">
|
||||
<span
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label?.color ?? "#000000",
|
||||
}}
|
||||
/>
|
||||
<div className="line-clamp-1 inline-block w-auto max-w-[100px] truncate">{label?.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<Tooltip tooltipContent={labelsString}>
|
||||
<div className="flex flex-shrink-0 cursor-default items-center rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm">
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<div className="text-xs">{labels.length} Labels</div>
|
||||
<div
|
||||
className={`flex h-full flex-shrink-0 items-center rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs cursor-not-allowed"
|
||||
`}
|
||||
>
|
||||
<Tooltip position="top" tooltipHeading="Labels" tooltipContent={labelsString}>
|
||||
<div className="flex h-full items-center gap-1.5 text-custom-text-200">
|
||||
<span className="h-2 w-2 flex-shrink-0 rounded-full bg-custom-primary" />
|
||||
{`${labels.length} Labels`}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// icons
|
||||
import { LucideIcon, Users } from "lucide-react";
|
||||
// ui
|
||||
import { cn } from "@plane/editor";
|
||||
import { Avatar, AvatarGroup } from "@plane/ui";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
//
|
||||
import { TPublicMember } from "@/types/member";
|
||||
|
||||
type Props = {
|
||||
memberIds: string[];
|
||||
shouldShowBorder?: boolean;
|
||||
};
|
||||
|
||||
type AvatarProps = {
|
||||
showTooltip: boolean;
|
||||
members: TPublicMember[];
|
||||
icon?: LucideIcon;
|
||||
};
|
||||
|
||||
export const ButtonAvatars: React.FC<AvatarProps> = observer((props: AvatarProps) => {
|
||||
const { showTooltip, members, icon: Icon } = props;
|
||||
|
||||
if (Array.isArray(members)) {
|
||||
if (members.length > 1) {
|
||||
return (
|
||||
<AvatarGroup size="md" showTooltip={!showTooltip}>
|
||||
{members.map((member) => {
|
||||
if (!member) return;
|
||||
return <Avatar key={member.id} src={member.member__avatar} name={member.member__display_name} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
);
|
||||
} else if (members.length === 1) {
|
||||
return (
|
||||
<Avatar
|
||||
src={members[0].member__avatar}
|
||||
name={members[0].member__display_name}
|
||||
size="md"
|
||||
showTooltip={!showTooltip}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Icon ? <Icon className="h-3 w-3 flex-shrink-0" /> : <Users className="h-3 w-3 mx-[4px] flex-shrink-0" />;
|
||||
});
|
||||
|
||||
export const IssueBlockMembers = observer(({ memberIds, shouldShowBorder = true }: Props) => {
|
||||
const { getMembersByIds } = useMember();
|
||||
|
||||
const members = getMembersByIds(memberIds);
|
||||
|
||||
return (
|
||||
<div className="relative h-full flex flex-wrap items-center gap-1">
|
||||
<div
|
||||
className={cn("flex flex-shrink-0 cursor-default items-center rounded-md text-xs", {
|
||||
"border-[0.5px] border-custom-border-300 px-2.5 py-1": shouldShowBorder && !members?.length,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<ButtonAvatars members={members} showTooltip={false} />
|
||||
{!shouldShowBorder && members.length <= 1 && (
|
||||
<span>{members?.[0]?.member__display_name ?? "No Assignees"}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// planes
|
||||
import { cn } from "@plane/editor";
|
||||
import { DiceIcon, Tooltip } from "@plane/ui";
|
||||
// hooks
|
||||
import { useModule } from "@/hooks/store/use-module";
|
||||
|
||||
type Props = {
|
||||
moduleIds: string[] | undefined;
|
||||
shouldShowBorder?: boolean;
|
||||
};
|
||||
|
||||
export const IssueBlockModules = observer(({ moduleIds, shouldShowBorder = true }: Props) => {
|
||||
const { getModulesByIds } = useModule();
|
||||
|
||||
const modules = getModulesByIds(moduleIds ?? []);
|
||||
|
||||
const modulesString = modules.map((module) => module.name).join(", ");
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full flex-wrap items-center gap-1">
|
||||
<Tooltip tooltipHeading="Modules" tooltipContent={modulesString}>
|
||||
{modules.length <= 1 ? (
|
||||
<div
|
||||
key={modules?.[0]?.id}
|
||||
className={cn("flex h-full flex-shrink-0 cursor-default items-center rounded-md px-2.5 py-1 text-xs", {
|
||||
"border-[0.5px] border-custom-border-300": shouldShowBorder,
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<DiceIcon className="h-3 w-3 flex-shrink-0" />
|
||||
<div className="text-xs">{modules?.[0]?.name ?? "No Modules"}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-shrink-0 cursor-default items-center rounded-md border border-custom-border-300 px-2.5 py-1 text-xs">
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<div className="text-xs">{modules.length} Modules</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -2,17 +2,29 @@
|
|||
|
||||
// types
|
||||
import { TIssuePriorities } from "@plane/types";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// constants
|
||||
import { issuePriorityFilter } from "@/constants/issue";
|
||||
|
||||
export const IssueBlockPriority = ({ priority }: { priority: TIssuePriorities | null }) => {
|
||||
export const IssueBlockPriority = ({
|
||||
priority,
|
||||
shouldShowName = false,
|
||||
}: {
|
||||
priority: TIssuePriorities | null;
|
||||
shouldShowName?: boolean;
|
||||
}) => {
|
||||
const priority_detail = priority != null ? issuePriorityFilter(priority) : null;
|
||||
|
||||
if (priority_detail === null) return <></>;
|
||||
|
||||
return (
|
||||
<div className={`grid h-6 w-6 place-items-center rounded border-[0.5px] ${priority_detail?.className}`}>
|
||||
<span className="material-symbols-rounded text-sm">{priority_detail?.icon}</span>
|
||||
</div>
|
||||
<Tooltip tooltipHeading="Priority" tooltipContent={priority_detail?.title}>
|
||||
<div className="flex items-center relative w-full h-full">
|
||||
<div className={`grid h-5 w-5 place-items-center rounded border-[0.5px] gap-2 ${priority_detail?.className}`}>
|
||||
<span className="material-symbols-rounded text-sm">{priority_detail?.icon}</span>
|
||||
</div>
|
||||
{shouldShowName && <span className="pl-2 text-sm">{priority_detail?.title}</span>}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,26 +2,32 @@
|
|||
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { StateGroupIcon } from "@plane/ui";
|
||||
import { cn } from "@plane/editor";
|
||||
import { StateGroupIcon, Tooltip } from "@plane/ui";
|
||||
//hooks
|
||||
import { useStates } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
stateId: string;
|
||||
stateId: string | undefined;
|
||||
shouldShowBorder?: boolean;
|
||||
};
|
||||
export const IssueBlockState = observer(({ stateId }: Props) => {
|
||||
export const IssueBlockState = observer(({ stateId, shouldShowBorder = true }: Props) => {
|
||||
const { getStateById } = useStates();
|
||||
|
||||
const state = getStateById(stateId);
|
||||
|
||||
if (!state) return <></>;
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs shadow-sm duration-300 focus:outline-none">
|
||||
<div className="flex w-full items-center gap-1.5">
|
||||
<StateGroupIcon stateGroup={state.group} color={state.color} />
|
||||
<div className="text-xs">{state?.name}</div>
|
||||
<Tooltip tooltipHeading="State" tooltipContent={state?.name ?? "State"}>
|
||||
<div
|
||||
className={cn("flex h-full w-full items-center justify-between gap-1 rounded px-2.5 py-1 text-xs", {
|
||||
"border-[0.5px] border-custom-border-300": shouldShowBorder,
|
||||
})}
|
||||
>
|
||||
<div className="flex w-full items-center gap-1.5">
|
||||
<StateGroupIcon stateGroup={state?.group ?? "backlog"} color={state?.color} />
|
||||
<div className="text-xs">{state?.name ?? "State"}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
240
space/core/components/issues/issue-layouts/utils.tsx
Normal file
240
space/core/components/issues/issue-layouts/utils.tsx
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
"use client";
|
||||
|
||||
import isNil from "lodash/isNil";
|
||||
import { ContrastIcon } from "lucide-react";
|
||||
// types
|
||||
import {
|
||||
GroupByColumnTypes,
|
||||
IGroupByColumn,
|
||||
TCycleGroups,
|
||||
IIssueDisplayProperties,
|
||||
TGroupedIssues,
|
||||
} from "@plane/types";
|
||||
// ui
|
||||
import { Avatar, CycleGroupIcon, DiceIcon, PriorityIcon, StateGroupIcon } from "@plane/ui";
|
||||
// components
|
||||
// constants
|
||||
import { ISSUE_PRIORITIES } from "@/constants/issue";
|
||||
// stores
|
||||
import { ICycleStore } from "@/store/cycle.store";
|
||||
import { IIssueLabelStore } from "@/store/label.store";
|
||||
import { IIssueMemberStore } from "@/store/members.store";
|
||||
import { IIssueModuleStore } from "@/store/module.store";
|
||||
import { IStateStore } from "@/store/state.store";
|
||||
|
||||
export const HIGHLIGHT_CLASS = "highlight";
|
||||
export const HIGHLIGHT_WITH_LINE = "highlight-with-line";
|
||||
|
||||
export const getGroupByColumns = (
|
||||
groupBy: GroupByColumnTypes | null,
|
||||
cycle: ICycleStore,
|
||||
module: IIssueModuleStore,
|
||||
label: IIssueLabelStore,
|
||||
projectState: IStateStore,
|
||||
member: IIssueMemberStore,
|
||||
includeNone?: boolean
|
||||
): IGroupByColumn[] | undefined => {
|
||||
switch (groupBy) {
|
||||
case "cycle":
|
||||
return getCycleColumns(cycle);
|
||||
case "module":
|
||||
return getModuleColumns(module);
|
||||
case "state":
|
||||
return getStateColumns(projectState);
|
||||
case "priority":
|
||||
return getPriorityColumns();
|
||||
case "labels":
|
||||
return getLabelsColumns(label) as any;
|
||||
case "assignees":
|
||||
return getAssigneeColumns(member) as any;
|
||||
case "created_by":
|
||||
return getCreatedByColumns(member) as any;
|
||||
default:
|
||||
if (includeNone) return [{ id: `All Issues`, name: `All Issues`, payload: {}, icon: undefined }];
|
||||
}
|
||||
};
|
||||
|
||||
const getCycleColumns = (cycleStore: ICycleStore): IGroupByColumn[] | undefined => {
|
||||
const { cycles } = cycleStore;
|
||||
|
||||
if (!cycles) return;
|
||||
|
||||
const cycleGroups: IGroupByColumn[] = [];
|
||||
|
||||
cycles.map((cycle) => {
|
||||
if (cycle) {
|
||||
const cycleStatus = cycle?.status ? (cycle.status.toLocaleLowerCase() as TCycleGroups) : "draft";
|
||||
cycleGroups.push({
|
||||
id: cycle.id,
|
||||
name: cycle.name,
|
||||
icon: <CycleGroupIcon cycleGroup={cycleStatus as TCycleGroups} className="h-3.5 w-3.5" />,
|
||||
payload: { cycle_id: cycle.id },
|
||||
});
|
||||
}
|
||||
});
|
||||
cycleGroups.push({
|
||||
id: "None",
|
||||
name: "None",
|
||||
icon: <ContrastIcon className="h-3.5 w-3.5" />,
|
||||
payload: { cycle_id: null },
|
||||
});
|
||||
|
||||
return cycleGroups;
|
||||
};
|
||||
|
||||
const getModuleColumns = (moduleStore: IIssueModuleStore): IGroupByColumn[] | undefined => {
|
||||
const { modules } = moduleStore;
|
||||
|
||||
if (!modules) return;
|
||||
|
||||
const moduleGroups: IGroupByColumn[] = [];
|
||||
|
||||
modules.map((moduleInfo) => {
|
||||
if (moduleInfo)
|
||||
moduleGroups.push({
|
||||
id: moduleInfo.id,
|
||||
name: moduleInfo.name,
|
||||
icon: <DiceIcon className="h-3.5 w-3.5" />,
|
||||
payload: { module_ids: [moduleInfo.id] },
|
||||
});
|
||||
}) as any;
|
||||
moduleGroups.push({
|
||||
id: "None",
|
||||
name: "None",
|
||||
icon: <DiceIcon className="h-3.5 w-3.5" />,
|
||||
payload: { module_ids: [] },
|
||||
});
|
||||
|
||||
return moduleGroups as any;
|
||||
};
|
||||
|
||||
const getStateColumns = (projectState: IStateStore): IGroupByColumn[] | undefined => {
|
||||
const { states } = projectState;
|
||||
if (!states) return;
|
||||
|
||||
return states.map((state) => ({
|
||||
id: state.id,
|
||||
name: state.name,
|
||||
icon: (
|
||||
<div className="h-3.5 w-3.5 rounded-full">
|
||||
<StateGroupIcon stateGroup={state.group} color={state.color} width="14" height="14" />
|
||||
</div>
|
||||
),
|
||||
payload: { state_id: state.id },
|
||||
})) as any;
|
||||
};
|
||||
|
||||
const getPriorityColumns = () => {
|
||||
const priorities = ISSUE_PRIORITIES;
|
||||
|
||||
return priorities.map((priority) => ({
|
||||
id: priority.key,
|
||||
name: priority.title,
|
||||
icon: <PriorityIcon priority={priority?.key} />,
|
||||
payload: { priority: priority.key },
|
||||
}));
|
||||
};
|
||||
|
||||
const getLabelsColumns = (label: IIssueLabelStore) => {
|
||||
const { labels: storeLabels } = label;
|
||||
|
||||
if (!storeLabels) return;
|
||||
|
||||
const labels = [...storeLabels, { id: "None", name: "None", color: "#666" }];
|
||||
|
||||
return labels.map((label) => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
icon: (
|
||||
<div className="h-[12px] w-[12px] rounded-full" style={{ backgroundColor: label.color ? label.color : "#666" }} />
|
||||
),
|
||||
payload: label?.id === "None" ? {} : { label_ids: [label.id] },
|
||||
}));
|
||||
};
|
||||
|
||||
const getAssigneeColumns = (member: IIssueMemberStore) => {
|
||||
const { members } = member;
|
||||
|
||||
if (!members) return;
|
||||
|
||||
const assigneeColumns: any = members.map((member) => ({
|
||||
id: member.id,
|
||||
name: member?.member__display_name || "",
|
||||
icon: <Avatar name={member?.member__display_name} src={undefined} size="md" />,
|
||||
payload: { assignee_ids: [member.id] },
|
||||
}));
|
||||
|
||||
assigneeColumns.push({ id: "None", name: "None", icon: <Avatar size="md" />, payload: {} });
|
||||
|
||||
return assigneeColumns;
|
||||
};
|
||||
|
||||
const getCreatedByColumns = (member: IIssueMemberStore) => {
|
||||
const { members } = member;
|
||||
|
||||
if (!members) return;
|
||||
|
||||
return members.map((member) => ({
|
||||
id: member.id,
|
||||
name: member?.member__display_name || "",
|
||||
icon: <Avatar name={member?.member__display_name} src={undefined} size="md" />,
|
||||
payload: {},
|
||||
}));
|
||||
};
|
||||
|
||||
export const getDisplayPropertiesCount = (
|
||||
displayProperties: IIssueDisplayProperties,
|
||||
ignoreFields?: (keyof IIssueDisplayProperties)[]
|
||||
) => {
|
||||
const propertyKeys = Object.keys(displayProperties) as (keyof IIssueDisplayProperties)[];
|
||||
|
||||
let count = 0;
|
||||
|
||||
for (const propertyKey of propertyKeys) {
|
||||
if (ignoreFields && ignoreFields.includes(propertyKey)) continue;
|
||||
if (displayProperties[propertyKey]) count++;
|
||||
}
|
||||
|
||||
return count;
|
||||
};
|
||||
|
||||
export const getIssueBlockId = (
|
||||
issueId: string | undefined,
|
||||
groupId: string | undefined,
|
||||
subGroupId?: string | undefined
|
||||
) => `issue_${issueId}_${groupId}_${subGroupId}`;
|
||||
|
||||
/**
|
||||
* returns empty Array if groupId is None
|
||||
* @param groupId
|
||||
* @returns
|
||||
*/
|
||||
export const getGroupId = (groupId: string) => {
|
||||
if (groupId === "None") return [];
|
||||
return [groupId];
|
||||
};
|
||||
|
||||
/**
|
||||
* method that removes Null or undefined Keys from object
|
||||
* @param obj
|
||||
* @returns
|
||||
*/
|
||||
export const removeNillKeys = <T,>(obj: T) =>
|
||||
Object.fromEntries(Object.entries(obj ?? {}).filter(([key, value]) => key && !isNil(value)));
|
||||
|
||||
/**
|
||||
* This Method returns if the the grouped values are subGrouped
|
||||
* @param groupedIssueIds
|
||||
* @returns
|
||||
*/
|
||||
export const isSubGrouped = (groupedIssueIds: TGroupedIssues) => {
|
||||
if (!groupedIssueIds || Array.isArray(groupedIssueIds)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(groupedIssueIds[Object.keys(groupedIssueIds)[0]])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { IIssueDisplayProperties } from "@plane/types";
|
||||
|
||||
interface IWithDisplayPropertiesHOC {
|
||||
displayProperties: IIssueDisplayProperties;
|
||||
shouldRenderProperty?: (displayProperties: IIssueDisplayProperties) => boolean;
|
||||
displayPropertyKey: keyof IIssueDisplayProperties | (keyof IIssueDisplayProperties)[];
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const WithDisplayPropertiesHOC = observer(
|
||||
({ displayProperties, shouldRenderProperty, displayPropertyKey, children }: IWithDisplayPropertiesHOC) => {
|
||||
let shouldDisplayPropertyFromFilters = false;
|
||||
if (Array.isArray(displayPropertyKey))
|
||||
shouldDisplayPropertyFromFilters = displayPropertyKey.every((key) => !!displayProperties[key]);
|
||||
else shouldDisplayPropertyFromFilters = !!displayProperties[displayPropertyKey];
|
||||
|
||||
const renderProperty =
|
||||
shouldDisplayPropertyFromFilters && (shouldRenderProperty ? shouldRenderProperty(displayProperties) : true);
|
||||
|
||||
if (!renderProperty) return null;
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
);
|
||||
|
|
@ -6,16 +6,17 @@ import { useRouter, useSearchParams } from "next/navigation";
|
|||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// components
|
||||
import { FullScreenPeekView, SidePeekView } from "@/components/issues/peek-overview";
|
||||
// store
|
||||
// hooks
|
||||
import { useIssue, useIssueDetails } from "@/hooks/store";
|
||||
|
||||
type TIssuePeekOverview = {
|
||||
anchor: string;
|
||||
peekId: string;
|
||||
handlePeekClose?: () => void;
|
||||
};
|
||||
|
||||
export const IssuePeekOverview: FC<TIssuePeekOverview> = observer((props) => {
|
||||
const { anchor, peekId } = props;
|
||||
const { anchor, peekId, handlePeekClose } = props;
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
// query params
|
||||
|
|
@ -34,13 +35,17 @@ export const IssuePeekOverview: FC<TIssuePeekOverview> = observer((props) => {
|
|||
|
||||
useEffect(() => {
|
||||
if (anchor && peekId && issueStore.groupedIssueIds) {
|
||||
if (!issueDetails) {
|
||||
issueDetailStore.fetchIssueDetails(anchor, peekId.toString());
|
||||
}
|
||||
issueDetailStore.fetchIssueDetails(anchor, peekId.toString());
|
||||
}
|
||||
}, [anchor, issueDetailStore, issueDetails, peekId, issueStore.groupedIssueIds]);
|
||||
}, [anchor, issueDetailStore, peekId, issueStore.groupedIssueIds]);
|
||||
|
||||
const handleClose = () => {
|
||||
// if close logic is passed down, call that instead of the below logic
|
||||
if (handlePeekClose) {
|
||||
handlePeekClose();
|
||||
return;
|
||||
}
|
||||
|
||||
issueDetailStore.setPeekId(null);
|
||||
let queryParams: any = {
|
||||
board,
|
||||
|
|
|
|||
26
space/core/components/ui/not-found.tsx
Normal file
26
space/core/components/ui/not-found.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
// ui
|
||||
// images
|
||||
import Image404 from "@/public/404.svg";
|
||||
|
||||
export const PageNotFound = () => (
|
||||
<div className={`h-screen w-full overflow-hidden bg-custom-background-100`}>
|
||||
<div className="grid h-full place-items-center p-4">
|
||||
<div className="space-y-8 text-center">
|
||||
<div className="relative mx-auto h-60 w-60 lg:h-80 lg:w-80">
|
||||
<Image src={Image404} layout="fill" alt="404- Page not found" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-semibold">Oops! Something went wrong.</h3>
|
||||
<p className="text-sm text-custom-text-200">
|
||||
Sorry, the page you are looking for cannot be found. It may have been removed, had its name changed, or is
|
||||
temporarily unavailable.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -75,4 +75,15 @@ export const issuePriorityFilter = (priorityKey: TIssuePriorities): TIssueFilter
|
|||
|
||||
if (currentIssuePriority) return currentIssuePriority;
|
||||
return undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export const ISSUE_PRIORITIES: {
|
||||
key: TIssuePriorities;
|
||||
title: string;
|
||||
}[] = [
|
||||
{ key: "urgent", title: "Urgent" },
|
||||
{ key: "high", title: "High" },
|
||||
{ key: "medium", title: "Medium" },
|
||||
{ key: "low", title: "Low" },
|
||||
{ key: "none", title: "None" },
|
||||
];
|
||||
|
|
@ -7,3 +7,6 @@ export * from "./use-issue-details";
|
|||
export * from "./use-issue-filter";
|
||||
export * from "./use-state";
|
||||
export * from "./use-label";
|
||||
export * from "./use-cycle";
|
||||
export * from "./use-module";
|
||||
export * from "./use-member";
|
||||
|
|
|
|||
11
space/core/hooks/store/use-cycle.ts
Normal file
11
space/core/hooks/store/use-cycle.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { useContext } from "react";
|
||||
// lib
|
||||
import { StoreContext } from "@/lib/store-provider";
|
||||
// store
|
||||
import { ICycleStore } from "@/store/cycle.store";
|
||||
|
||||
export const useCycle = (): ICycleStore => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useCycle must be used within StoreProvider");
|
||||
return context.cycle;
|
||||
};
|
||||
11
space/core/hooks/store/use-member.ts
Normal file
11
space/core/hooks/store/use-member.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { useContext } from "react";
|
||||
// lib
|
||||
import { StoreContext } from "@/lib/store-provider";
|
||||
// store
|
||||
import { IIssueMemberStore } from "@/store/members.store";
|
||||
|
||||
export const useMember = (): IIssueMemberStore => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useMember must be used within StoreProvider");
|
||||
return context.member;
|
||||
};
|
||||
11
space/core/hooks/store/use-module.ts
Normal file
11
space/core/hooks/store/use-module.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { useContext } from "react";
|
||||
// lib
|
||||
import { StoreContext } from "@/lib/store-provider";
|
||||
// store
|
||||
import { IIssueModuleStore } from "@/store/module.store";
|
||||
|
||||
export const useModule = (): IIssueModuleStore => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useModule must be used within StoreProvider");
|
||||
return context.module;
|
||||
};
|
||||
17
space/core/services/cycle.service.ts
Normal file
17
space/core/services/cycle.service.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
import { APIService } from "@/services/api.service";
|
||||
import { TPublicCycle } from "@/types/cycle";
|
||||
|
||||
export class CycleService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async getCycles(anchor: string): Promise<TPublicCycle[]> {
|
||||
return this.get(`api/public/anchor/${anchor}/cycles/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
17
space/core/services/member.service.ts
Normal file
17
space/core/services/member.service.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
import { APIService } from "@/services/api.service";
|
||||
import { TPublicMember } from "@/types/member";
|
||||
|
||||
export class MemberService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async getAnchorMembers(anchor: string): Promise<TPublicMember[]> {
|
||||
return this.get(`api/public/anchor/${anchor}/members/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
17
space/core/services/module.service.ts
Normal file
17
space/core/services/module.service.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
import { APIService } from "@/services/api.service";
|
||||
import { TPublicModule } from "@/types/modules";
|
||||
|
||||
export class ModuleService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async getModules(anchor: string): Promise<TPublicModule[]> {
|
||||
return this.get(`api/public/anchor/${anchor}/modules/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
40
space/core/store/cycle.store.ts
Normal file
40
space/core/store/cycle.store.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
import { TPublicCycle } from "@/types/cycle";
|
||||
import { CycleService } from "../services/cycle.service";
|
||||
import { CoreRootStore } from "./root.store";
|
||||
|
||||
export interface ICycleStore {
|
||||
// observables
|
||||
cycles: TPublicCycle[] | undefined;
|
||||
// computed actions
|
||||
getCycleById: (cycleId: string | undefined) => TPublicCycle | undefined;
|
||||
// fetch actions
|
||||
fetchCycles: (anchor: string) => Promise<TPublicCycle[]>;
|
||||
}
|
||||
|
||||
export class CycleStore implements ICycleStore {
|
||||
cycles: TPublicCycle[] | undefined = undefined;
|
||||
cycleService: CycleService;
|
||||
rootStore: CoreRootStore;
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
cycles: observable,
|
||||
// fetch action
|
||||
fetchCycles: action,
|
||||
});
|
||||
this.cycleService = new CycleService();
|
||||
this.rootStore = _rootStore;
|
||||
}
|
||||
|
||||
getCycleById = (cycleId: string | undefined) => this.cycles?.find((cycle) => cycle.id === cycleId);
|
||||
|
||||
fetchCycles = async (anchor: string) => {
|
||||
const cyclesResponse = await this.cycleService.getCycles(anchor);
|
||||
runInAction(() => {
|
||||
this.cycles = cyclesResponse;
|
||||
});
|
||||
return cyclesResponse;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import concat from "lodash/concat";
|
||||
import get from "lodash/get";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import set from "lodash/set";
|
||||
import uniq from "lodash/uniq";
|
||||
import update from "lodash/update";
|
||||
|
|
@ -23,7 +22,6 @@ import {
|
|||
// services
|
||||
import IssueService from "@/services/issue.service";
|
||||
import { IIssue, TIssuesResponse } from "@/types/issue";
|
||||
import { IIssueFilterStore } from "../issue-filters.store";
|
||||
import { CoreRootStore } from "../root.store";
|
||||
// constants
|
||||
// helpers
|
||||
|
|
@ -39,14 +37,9 @@ export enum EIssueGroupedAction {
|
|||
export interface IBaseIssuesStore {
|
||||
// observable
|
||||
loader: Record<string, TLoader>;
|
||||
issuesMap: Record<string, IIssue>; // Record defines issue_id as key and IIssue as value
|
||||
// actions
|
||||
addIssue(issues: IIssue[], shouldReplace?: boolean): void;
|
||||
// helper methods
|
||||
getIssueById(issueId: string): undefined | IIssue;
|
||||
|
||||
fetchIssueById(anchorId: string, issueId: string): Promise<IIssue | undefined>;
|
||||
|
||||
groupedIssueIds: TGroupedIssues | TSubGroupedIssues | undefined; // object to store Issue Ids based on group or subgroup
|
||||
groupedIssueCount: TGroupedIssueCount; // map of groupId/subgroup and issue count of that particular group/subgroup
|
||||
issuePaginationData: TIssuePaginationData; // map of groupId/subgroup and pagination Data of that particular group/subgroup
|
||||
|
|
@ -79,7 +72,6 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore {
|
|||
loader: Record<string, TLoader> = {};
|
||||
groupedIssueIds: TIssues | undefined = undefined;
|
||||
issuePaginationData: TIssuePaginationData = {};
|
||||
issuesMap: Record<string, IIssue> = {}; // Record defines issue_id as key and TIssue as value
|
||||
groupedIssueCount: TGroupedIssueCount = {};
|
||||
//
|
||||
paginationOptions: IssuePaginationOptions | undefined = undefined;
|
||||
|
|
@ -87,9 +79,8 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore {
|
|||
issueService;
|
||||
// root store
|
||||
rootIssueStore;
|
||||
issueFilterStore;
|
||||
|
||||
constructor(_rootStore: CoreRootStore, issueFilterStore: IIssueFilterStore) {
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observable
|
||||
loader: observable,
|
||||
|
|
@ -107,7 +98,6 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore {
|
|||
setLoader: action.bound,
|
||||
});
|
||||
this.rootIssueStore = _rootStore;
|
||||
this.issueFilterStore = issueFilterStore;
|
||||
this.issueService = new IssueService();
|
||||
}
|
||||
|
||||
|
|
@ -141,35 +131,12 @@ export abstract class BaseIssuesStore implements IBaseIssuesStore {
|
|||
if (issues && issues.length <= 0) return;
|
||||
runInAction(() => {
|
||||
issues.forEach((issue) => {
|
||||
if (!this.issuesMap[issue.id] || shouldReplace) set(this.issuesMap, issue.id, issue);
|
||||
if (!this.rootIssueStore.issueDetail.getIssueById(issue.id) || shouldReplace)
|
||||
set(this.rootIssueStore.issueDetail.details, issue.id, issue);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description This method will return the issue from the issuesMap
|
||||
* @param {string} issueId
|
||||
* @returns {IIssue | undefined}
|
||||
*/
|
||||
getIssueById = computedFn((issueId: string) => {
|
||||
if (!issueId || isEmpty(this.issuesMap) || !this.issuesMap[issueId]) return undefined;
|
||||
return this.issuesMap[issueId];
|
||||
});
|
||||
|
||||
fetchIssueById = async (anchorId: string, issueId: string) => {
|
||||
try {
|
||||
const issueDetails = await this.issueService.getIssueById(anchorId, issueId);
|
||||
|
||||
runInAction(() => {
|
||||
set(this.issuesMap, [issueId], issueDetails);
|
||||
});
|
||||
|
||||
return issueDetails;
|
||||
} catch (e) {
|
||||
console.error("error fetching issue details");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Store the pagination data required for next subsequent issue pagination calls
|
||||
* @param prevCursor cursor value of previous page
|
||||
|
|
|
|||
|
|
@ -32,6 +32,16 @@ export const getPaginationParams = (
|
|||
paginationParams.group_by = EIssueGroupByToServerOptions[options.groupedBy];
|
||||
}
|
||||
|
||||
// If group by is specifically sent through options, like that for calendar layout, use that to group
|
||||
if (options.subGroupedBy) {
|
||||
paginationParams.sub_group_by = EIssueGroupByToServerOptions[options.subGroupedBy];
|
||||
}
|
||||
|
||||
// If group by is specifically sent through options, like that for calendar layout, use that to group
|
||||
if (options.orderBy) {
|
||||
paginationParams.order_by = options.orderBy;
|
||||
}
|
||||
|
||||
// If before and after dates are sent from option to filter by then, add them to filter the options
|
||||
if (options.after && options.before) {
|
||||
paginationParams["target_date"] = `${options.after};after,${options.before};before`;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import isEmpty from "lodash/isEmpty";
|
||||
import set from "lodash/set";
|
||||
import { makeObservable, observable, action, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// services
|
||||
import IssueService from "@/services/issue.service";
|
||||
|
|
@ -17,7 +19,10 @@ export interface IIssueDetailStore {
|
|||
details: {
|
||||
[key: string]: IIssue;
|
||||
};
|
||||
// computed actions
|
||||
getIsIssuePeeked: (issueID: string) => boolean;
|
||||
// actions
|
||||
getIssueById: (issueId: string) => IIssue | undefined;
|
||||
setPeekId: (issueID: string | null) => void;
|
||||
setPeekMode: (mode: IPeekMode) => void;
|
||||
// issue actions
|
||||
|
|
@ -88,6 +93,38 @@ export class IssueDetailStore implements IIssueDetailStore {
|
|||
this.peekMode = mode;
|
||||
};
|
||||
|
||||
getIsIssuePeeked = (issueID: string) => this.peekId === issueID;
|
||||
|
||||
/**
|
||||
* @description This method will return the issue from the issuesMap
|
||||
* @param {string} issueId
|
||||
* @returns {IIssue | undefined}
|
||||
*/
|
||||
getIssueById = computedFn((issueId: string) => {
|
||||
if (!issueId || isEmpty(this.details) || !this.details[issueId]) return undefined;
|
||||
return this.details[issueId];
|
||||
});
|
||||
|
||||
/**
|
||||
* Retrieves issue from API
|
||||
* @param anchorId ]
|
||||
* @param issueId
|
||||
* @returns
|
||||
*/
|
||||
fetchIssueById = async (anchorId: string, issueId: string) => {
|
||||
try {
|
||||
const issueDetails = await this.issueService.getIssueById(anchorId, issueId);
|
||||
|
||||
runInAction(() => {
|
||||
set(this.details, [issueId], issueDetails);
|
||||
});
|
||||
|
||||
return issueDetails;
|
||||
} catch (e) {
|
||||
console.error(`Error fetching issue details for issueId ${issueId}: `, e);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description fetc
|
||||
* @param {string} anchor
|
||||
|
|
@ -98,7 +135,7 @@ export class IssueDetailStore implements IIssueDetailStore {
|
|||
this.loader = true;
|
||||
this.error = null;
|
||||
|
||||
const issueDetails = await this.rootStore.issue.fetchIssueById(anchor, issueID);
|
||||
const issueDetails = await this.fetchIssueById(anchor, issueID);
|
||||
const commentsResponse = await this.issueService.getIssueComments(anchor, issueID);
|
||||
|
||||
if (issueDetails) {
|
||||
|
|
@ -120,11 +157,11 @@ export class IssueDetailStore implements IIssueDetailStore {
|
|||
|
||||
addIssueComment = async (anchor: string, issueID: string, data: any) => {
|
||||
try {
|
||||
const issueDetails = this.rootStore.issue.getIssueById(issueID);
|
||||
const issueDetails = this.getIssueById(issueID);
|
||||
const issueCommentResponse = await this.issueService.createIssueComment(anchor, issueID, data);
|
||||
if (issueDetails) {
|
||||
runInAction(() => {
|
||||
set(this.details, [issueID, "comments"], [...this.details[issueID].comments, issueCommentResponse]);
|
||||
set(this.details, [issueID, "comments"], [...(issueDetails?.comments ?? []), issueCommentResponse]);
|
||||
});
|
||||
}
|
||||
return issueCommentResponse;
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export class IssueStore extends BaseIssuesStore implements IIssueStore {
|
|||
issueService: IssueService;
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
super(_rootStore, _rootStore.issueFilter);
|
||||
super(_rootStore);
|
||||
makeObservable(this, {
|
||||
// actions
|
||||
fetchPublicIssues: action,
|
||||
|
|
|
|||
68
space/core/store/members.store.ts
Normal file
68
space/core/store/members.store.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import set from "lodash/set";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { TPublicMember } from "@/types/member";
|
||||
import { MemberService } from "../services/member.service";
|
||||
import { CoreRootStore } from "./root.store";
|
||||
|
||||
export interface IIssueMemberStore {
|
||||
// observables
|
||||
members: TPublicMember[] | undefined;
|
||||
// computed actions
|
||||
getMemberById: (memberId: string | undefined) => TPublicMember | undefined;
|
||||
getMembersByIds: (memberIds: string[]) => TPublicMember[];
|
||||
// fetch actions
|
||||
fetchMembers: (anchor: string) => Promise<TPublicMember[]>;
|
||||
}
|
||||
|
||||
export class MemberStore implements IIssueMemberStore {
|
||||
memberMap: Record<string, TPublicMember> = {};
|
||||
memberService: MemberService;
|
||||
rootStore: CoreRootStore;
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
memberMap: observable,
|
||||
// computed
|
||||
members: computed,
|
||||
// fetch action
|
||||
fetchMembers: action,
|
||||
});
|
||||
this.memberService = new MemberService();
|
||||
this.rootStore = _rootStore;
|
||||
}
|
||||
|
||||
get members() {
|
||||
return Object.values(this.memberMap);
|
||||
}
|
||||
|
||||
getMemberById = (memberId: string | undefined) => (memberId ? this.memberMap[memberId] : undefined);
|
||||
|
||||
getMembersByIds = (memberIds: string[]) => {
|
||||
const currMembers = [];
|
||||
for (const memberId of memberIds) {
|
||||
const member = this.getMemberById(memberId);
|
||||
if (member) {
|
||||
currMembers.push(member);
|
||||
}
|
||||
}
|
||||
|
||||
return currMembers;
|
||||
};
|
||||
|
||||
fetchMembers = async (anchor: string) => {
|
||||
try {
|
||||
const membersResponse = await this.memberService.getAnchorMembers(anchor);
|
||||
runInAction(() => {
|
||||
this.memberMap = {};
|
||||
for (const member of membersResponse) {
|
||||
set(this.memberMap, [member.member], member);
|
||||
}
|
||||
});
|
||||
return membersResponse;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch members:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
}
|
||||
68
space/core/store/module.store.ts
Normal file
68
space/core/store/module.store.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import set from "lodash/set";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { TPublicModule } from "@/types/modules";
|
||||
import { ModuleService } from "../services/module.service";
|
||||
import { CoreRootStore } from "./root.store";
|
||||
|
||||
export interface IIssueModuleStore {
|
||||
// observables
|
||||
modules: TPublicModule[] | undefined;
|
||||
// computed actions
|
||||
getModuleById: (moduleId: string | undefined) => TPublicModule | undefined;
|
||||
getModulesByIds: (moduleIds: string[]) => TPublicModule[];
|
||||
// fetch actions
|
||||
fetchModules: (anchor: string) => Promise<TPublicModule[]>;
|
||||
}
|
||||
|
||||
export class ModuleStore implements IIssueModuleStore {
|
||||
moduleMap: Record<string, TPublicModule> = {};
|
||||
moduleService: ModuleService;
|
||||
rootStore: CoreRootStore;
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
moduleMap: observable,
|
||||
// computed
|
||||
modules: computed,
|
||||
// fetch action
|
||||
fetchModules: action,
|
||||
});
|
||||
this.moduleService = new ModuleService();
|
||||
this.rootStore = _rootStore;
|
||||
}
|
||||
|
||||
get modules() {
|
||||
return Object.values(this.moduleMap);
|
||||
}
|
||||
|
||||
getModuleById = (moduleId: string | undefined) => (moduleId ? this.moduleMap[moduleId] : undefined);
|
||||
|
||||
getModulesByIds = (moduleIds: string[]) => {
|
||||
const currModules = [];
|
||||
for (const moduleId of moduleIds) {
|
||||
const issueModule = this.getModuleById(moduleId);
|
||||
if (issueModule) {
|
||||
currModules.push(issueModule);
|
||||
}
|
||||
}
|
||||
|
||||
return currModules;
|
||||
};
|
||||
|
||||
fetchModules = async (anchor: string) => {
|
||||
try {
|
||||
const modulesResponse = await this.moduleService.getModules(anchor);
|
||||
runInAction(() => {
|
||||
this.moduleMap = {};
|
||||
for (const issueModule of modulesResponse) {
|
||||
set(this.moduleMap, [issueModule.id], issueModule);
|
||||
}
|
||||
});
|
||||
return modulesResponse;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch members:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -4,9 +4,12 @@ import { IInstanceStore, InstanceStore } from "@/store/instance.store";
|
|||
import { IssueDetailStore, IIssueDetailStore } from "@/store/issue-detail.store";
|
||||
import { IssueStore, IIssueStore } from "@/store/issue.store";
|
||||
import { IUserStore, UserStore } from "@/store/user.store";
|
||||
import { CycleStore, ICycleStore } from "./cycle.store";
|
||||
import { IssueFilterStore, IIssueFilterStore } from "./issue-filters.store";
|
||||
import { IIssueLabelStore, LabelStore } from "./label.store";
|
||||
import { IIssueMemberStore, MemberStore } from "./members.store";
|
||||
import { IMentionsStore, MentionsStore } from "./mentions.store";
|
||||
import { IIssueModuleStore, ModuleStore } from "./module.store";
|
||||
import { IPublishListStore, PublishListStore } from "./publish/publish_list.store";
|
||||
import { IStateStore, StateStore } from "./state.store";
|
||||
|
||||
|
|
@ -20,6 +23,9 @@ export class CoreRootStore {
|
|||
mentionStore: IMentionsStore;
|
||||
state: IStateStore;
|
||||
label: IIssueLabelStore;
|
||||
module: IIssueModuleStore;
|
||||
member: IIssueMemberStore;
|
||||
cycle: ICycleStore;
|
||||
issueFilter: IIssueFilterStore;
|
||||
publishList: IPublishListStore;
|
||||
|
||||
|
|
@ -31,6 +37,9 @@ export class CoreRootStore {
|
|||
this.mentionStore = new MentionsStore(this);
|
||||
this.state = new StateStore(this);
|
||||
this.label = new LabelStore(this);
|
||||
this.module = new ModuleStore(this);
|
||||
this.member = new MemberStore(this);
|
||||
this.cycle = new CycleStore(this);
|
||||
this.issueFilter = new IssueFilterStore(this);
|
||||
this.publishList = new PublishListStore(this);
|
||||
}
|
||||
|
|
@ -51,6 +60,9 @@ export class CoreRootStore {
|
|||
this.mentionStore = new MentionsStore(this);
|
||||
this.state = new StateStore(this);
|
||||
this.label = new LabelStore(this);
|
||||
this.module = new ModuleStore(this);
|
||||
this.member = new MemberStore(this);
|
||||
this.cycle = new CycleStore(this);
|
||||
this.issueFilter = new IssueFilterStore(this);
|
||||
this.publishList = new PublishListStore(this);
|
||||
}
|
||||
|
|
|
|||
5
space/core/types/cycle.d.ts
vendored
Normal file
5
space/core/types/cycle.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export type TPublicCycle = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
};
|
||||
6
space/core/types/issue.d.ts
vendored
6
space/core/types/issue.d.ts
vendored
|
|
@ -37,6 +37,8 @@ export interface IIssue
|
|||
extends Pick<
|
||||
TIssue,
|
||||
| "description_html"
|
||||
| "created_at"
|
||||
| "updated_at"
|
||||
| "created_by"
|
||||
| "id"
|
||||
| "name"
|
||||
|
|
@ -51,6 +53,10 @@ export interface IIssue
|
|||
| "module_ids"
|
||||
| "label_ids"
|
||||
| "assignee_ids"
|
||||
| "attachment_count"
|
||||
| "sub_issues_count"
|
||||
| "link_count"
|
||||
| "estimate_point"
|
||||
> {
|
||||
comments: Comment[];
|
||||
reaction_items: IIssueReaction[];
|
||||
|
|
|
|||
10
space/core/types/member.d.ts
vendored
Normal file
10
space/core/types/member.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export type TPublicMember = {
|
||||
id: string;
|
||||
member: string;
|
||||
member__avatar: string;
|
||||
member__first_name: string;
|
||||
member__last_name: string;
|
||||
member__display_name: string;
|
||||
project: string;
|
||||
workspace: string;
|
||||
};
|
||||
4
space/core/types/modules.d.ts
vendored
Normal file
4
space/core/types/modules.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export type TPublicModule = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
1
space/ee/components/issue-layouts/root.tsx
Normal file
1
space/ee/components/issue-layouts/root.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "ce/components/issue-layouts/root";
|
||||
1
space/ee/components/navbar/index.tsx
Normal file
1
space/ee/components/navbar/index.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "ce/components/navbar";
|
||||
1
space/ee/hooks/store/index.ts
Normal file
1
space/ee/hooks/store/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "ce/hooks/store";
|
||||
|
|
@ -56,3 +56,7 @@ export const isEmptyHtmlString = (htmlString: string) => {
|
|||
// Trim the string and check if it's empty
|
||||
return cleanText.trim() === "";
|
||||
};
|
||||
|
||||
export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " ");
|
||||
|
||||
export const capitalizeFirstLetter = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
|
||||
|
|
@ -354,3 +354,90 @@ body {
|
|||
.disable-autofill-style:-webkit-autofill:active {
|
||||
-webkit-background-clip: text;
|
||||
}
|
||||
|
||||
|
||||
@-moz-document url-prefix() {
|
||||
* {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.vertical-scrollbar,
|
||||
.horizontal-scrollbar {
|
||||
scrollbar-width: initial;
|
||||
scrollbar-color: rgba(96, 100, 108, 0.1) transparent;
|
||||
}
|
||||
.vertical-scrollbar:hover,
|
||||
.horizontal-scrollbar:hover {
|
||||
scrollbar-color: rgba(96, 100, 108, 0.25) transparent;
|
||||
}
|
||||
.vertical-scrollbar:active,
|
||||
.horizontal-scrollbar:active {
|
||||
scrollbar-color: rgba(96, 100, 108, 0.7) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.vertical-scrollbar {
|
||||
overflow-y: auto;
|
||||
}
|
||||
.horizontal-scrollbar {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.vertical-scrollbar::-webkit-scrollbar,
|
||||
.horizontal-scrollbar::-webkit-scrollbar {
|
||||
display: block;
|
||||
}
|
||||
.vertical-scrollbar::-webkit-scrollbar-track,
|
||||
.horizontal-scrollbar::-webkit-scrollbar-track {
|
||||
background-color: transparent;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.vertical-scrollbar::-webkit-scrollbar-thumb,
|
||||
.horizontal-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-clip: padding-box;
|
||||
background-color: rgba(96, 100, 108, 0.1);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.vertical-scrollbar:hover::-webkit-scrollbar-thumb,
|
||||
.horizontal-scrollbar:hover::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(96, 100, 108, 0.25);
|
||||
}
|
||||
.vertical-scrollbar::-webkit-scrollbar-thumb:hover,
|
||||
.horizontal-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(96, 100, 108, 0.5);
|
||||
}
|
||||
.vertical-scrollbar::-webkit-scrollbar-thumb:active,
|
||||
.horizontal-scrollbar::-webkit-scrollbar-thumb:active {
|
||||
background-color: rgba(96, 100, 108, 0.7);
|
||||
}
|
||||
.vertical-scrollbar::-webkit-scrollbar-corner,
|
||||
.horizontal-scrollbar::-webkit-scrollbar-corner {
|
||||
background-color: transparent;
|
||||
}
|
||||
.vertical-scrollbar-margin-top-md::-webkit-scrollbar-track {
|
||||
margin-top: 44px;
|
||||
}
|
||||
|
||||
/* scrollbar sm size */
|
||||
.scrollbar-sm::-webkit-scrollbar {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}
|
||||
.scrollbar-sm::-webkit-scrollbar-thumb {
|
||||
border: 3px solid rgba(0, 0, 0, 0);
|
||||
}
|
||||
/* scrollbar md size */
|
||||
.scrollbar-md::-webkit-scrollbar {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
.scrollbar-md::-webkit-scrollbar-thumb {
|
||||
border: 3px solid rgba(0, 0, 0, 0);
|
||||
}
|
||||
/* scrollbar lg size */
|
||||
|
||||
.scrollbar-lg::-webkit-scrollbar {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
.scrollbar-lg::-webkit-scrollbar-thumb {
|
||||
border: 4px solid rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
|
|
|||
2
web/ce/components/views/publish/index.ts
Normal file
2
web/ce/components/views/publish/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./modal";
|
||||
export * from "./use-view-publish";
|
||||
12
web/ce/components/views/publish/modal.tsx
Normal file
12
web/ce/components/views/publish/modal.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { IProjectView } from "@plane/types";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
view: IProjectView;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const PublishViewModal = (props: Props) => <></>;
|
||||
7
web/ce/components/views/publish/use-view-publish.tsx
Normal file
7
web/ce/components/views/publish/use-view-publish.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const useViewPublish = (isPublished: boolean, isAuthorized: boolean) => ({
|
||||
isPublishModalOpen: false,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
setPublishModalOpen: (value: boolean) => {},
|
||||
publishContextMenu: undefined,
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { TPublishViewSettings } from "@plane/types";
|
||||
import { EViewAccess } from "@/constants/views";
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
import { ViewService as CoreViewService } from "@/services/view.service";
|
||||
|
|
@ -21,4 +22,40 @@ export class ViewService extends CoreViewService {
|
|||
async unLockView(workspaceSlug: string, projectId: string, viewId: string) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async getPublishDetails(workspaceSlug: string, projectId: string, viewId: string): Promise<any> {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
|
||||
async publishView(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
workspaceSlug: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
projectId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
viewId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
data: TPublishViewSettings
|
||||
): Promise<any> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async updatePublishedView(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
workspaceSlug: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
projectId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
viewId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
data: Partial<TPublishViewSettings>
|
||||
): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async unPublishView(workspaceSlug: string, projectId: string, viewId: string): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { cn } from "@/helpers/common.helper";
|
|||
import { copyUrlToClipboard } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useUser } from "@/hooks/store";
|
||||
import { PublishViewModal, useViewPublish } from "@/plane-web/components/views/publish";
|
||||
|
||||
type Props = {
|
||||
parentRef: React.RefObject<HTMLElement>;
|
||||
|
|
@ -38,6 +39,11 @@ export const ViewQuickActions: React.FC<Props> = observer((props) => {
|
|||
const isOwner = view?.owned_by === data?.id;
|
||||
const isAdmin = !!currentProjectRole && currentProjectRole == EUserProjectRoles.ADMIN;
|
||||
|
||||
const { isPublishModalOpen, setPublishModalOpen, publishContextMenu } = useViewPublish(
|
||||
!!view.anchor,
|
||||
isAdmin || isOwner
|
||||
);
|
||||
|
||||
const viewLink = `${workspaceSlug}/projects/${projectId}/views/${view.id}`;
|
||||
const handleCopyText = () =>
|
||||
copyUrlToClipboard(viewLink).then(() => {
|
||||
|
|
@ -78,6 +84,8 @@ export const ViewQuickActions: React.FC<Props> = observer((props) => {
|
|||
},
|
||||
];
|
||||
|
||||
if (publishContextMenu) MENU_ITEMS.splice(2, 0, publishContextMenu);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateProjectViewModal
|
||||
|
|
@ -88,6 +96,7 @@ export const ViewQuickActions: React.FC<Props> = observer((props) => {
|
|||
data={view}
|
||||
/>
|
||||
<DeleteProjectViewModal data={view} isOpen={deleteViewModal} onClose={() => setDeleteViewModal(false)} />
|
||||
<PublishViewModal isOpen={isPublishModalOpen} onClose={() => setPublishModalOpen(false)} view={view} />
|
||||
<ContextMenu parentRef={parentRef} items={MENU_ITEMS} />
|
||||
<CustomMenu ellipsis placement="bottom-end" closeOnSelect>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { set } from "lodash";
|
|||
import { observable, action, makeObservable, runInAction, computed } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// types
|
||||
import { IProjectView, TViewFilters } from "@plane/types";
|
||||
import { IProjectView, TPublishViewDetails, TPublishViewSettings, TViewFilters } from "@plane/types";
|
||||
// constants
|
||||
import { EViewAccess } from "@/constants/views";
|
||||
// helpers
|
||||
|
|
@ -42,6 +42,25 @@ export interface IProjectViewStore {
|
|||
// favorites actions
|
||||
addViewToFavorites: (workspaceSlug: string, projectId: string, viewId: string) => Promise<any>;
|
||||
removeViewFromFavorites: (workspaceSlug: string, projectId: string, viewId: string) => Promise<any>;
|
||||
// publish
|
||||
publishView: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
viewId: string,
|
||||
data: TPublishViewSettings
|
||||
) => Promise<TPublishViewDetails | undefined>;
|
||||
fetchPublishDetails: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
viewId: string
|
||||
) => Promise<TPublishViewDetails | undefined>;
|
||||
updatePublishedView: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
viewId: string,
|
||||
data: Partial<TPublishViewSettings>
|
||||
) => Promise<void>;
|
||||
unPublishView: (workspaceSlug: string, projectId: string, viewId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export class ProjectViewStore implements IProjectViewStore {
|
||||
|
|
@ -372,4 +391,91 @@ export class ProjectViewStore implements IProjectViewStore {
|
|||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Publishes View to the Public
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param viewId
|
||||
* @returns
|
||||
*/
|
||||
publishView = async (workspaceSlug: string, projectId: string, viewId: string, data: TPublishViewSettings) => {
|
||||
try {
|
||||
const response = (await this.viewService.publishView(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
viewId,
|
||||
data
|
||||
)) as TPublishViewDetails;
|
||||
runInAction(() => {
|
||||
set(this.viewMap, [viewId, "anchor"], response?.anchor);
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Failed to publish view", error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* fetches Published Details
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param viewId
|
||||
* @returns
|
||||
*/
|
||||
fetchPublishDetails = async (workspaceSlug: string, projectId: string, viewId: string) => {
|
||||
try {
|
||||
const response = (await this.viewService.getPublishDetails(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
viewId
|
||||
)) as TPublishViewDetails;
|
||||
runInAction(() => {
|
||||
set(this.viewMap, [viewId, "anchor"], response?.anchor);
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch published view details", error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* updates already published view
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param viewId
|
||||
* @returns
|
||||
*/
|
||||
updatePublishedView = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
viewId: string,
|
||||
data: Partial<TPublishViewSettings>
|
||||
) => {
|
||||
try {
|
||||
return await this.viewService.updatePublishedView(workspaceSlug, projectId, viewId, data);
|
||||
} catch (error) {
|
||||
console.error("Failed to update published view details", error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* un publishes the view
|
||||
* @param workspaceSlug
|
||||
* @param projectId
|
||||
* @param viewId
|
||||
* @returns
|
||||
*/
|
||||
unPublishView = async (workspaceSlug: string, projectId: string, viewId: string) => {
|
||||
try {
|
||||
const response = await this.viewService.unPublishView(workspaceSlug, projectId, viewId);
|
||||
runInAction(() => {
|
||||
set(this.viewMap, [viewId, "anchor"], null);
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Failed to unPublish view", error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
1
web/ee/components/views/publish/index.ts
Normal file
1
web/ee/components/views/publish/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "ce/components/views/publish";
|
||||
Loading…
Add table
Add a link
Reference in a new issue