feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
import Image from "next/image";
// assets
import SomethingWentWrongImage from "public/something-went-wrong.svg";
export const SomethingWentWrongError = () => (
<div className="grid min-h-screen w-full place-items-center p-6">
<div className="text-center">
<div className="mx-auto grid h-52 w-52 place-items-center rounded-full bg-custom-background-80">
<div className="grid h-32 w-32 place-items-center">
<Image src={SomethingWentWrongImage} alt="Oops! Something went wrong" />
</div>
</div>
<h1 className="mt-12 text-3xl font-semibold">Oops! Something went wrong.</h1>
<p className="mt-4 text-custom-text-300">The public board does not exist. Please check the URL.</p>
</div>
</div>
);

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,35 @@
import { observer } from "mobx-react";
// plane imports
import type { TLoader } from "@plane/types";
// components
import { LogoSpinner } from "@/components/common/logo-spinner";
interface Props {
children: string | React.ReactNode | React.ReactNode[];
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 work items Found</div>;
}
return <>{props.children}</>;
});

View File

@@ -0,0 +1,76 @@
"use client";
import { useCallback, useMemo, useRef } from "react";
import { debounce } from "lodash-es";
import { observer } from "mobx-react";
// types
import type { IIssueDisplayProperties } from "@plane/types";
// components
import { IssueLayoutHOC } from "@/components/issues/issue-layouts/issue-layout-HOC";
// hooks
import { useIssue } from "@/hooks/store/use-issue";
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);
}
},
[anchor, getIssueLoader, 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>
);
});

View File

@@ -0,0 +1,46 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// plane utils
import { cn } from "@plane/utils";
// components
import { IssueEmojiReactions } from "@/components/issues/reactions/issue-emoji-reactions";
import { IssueVotes } from "@/components/issues/reactions/issue-vote-reactions";
// hooks
import { usePublish } from "@/hooks/store/publish";
type Props = {
issueId: string;
};
export const BlockReactions = observer((props: Props) => {
const { issueId } = props;
const { anchor } = useParams();
const { canVote, canReact } = usePublish(anchor.toString());
// if the user cannot vote or react then return empty
if (!canVote && !canReact) return <></>;
return (
<div
className={cn(
"flex flex-wrap border-t-[1px] outline-transparent w-full border-t-custom-border-200 bg-custom-background-90 rounded-b"
)}
>
<div className="py-2 px-3 flex flex-wrap items-center gap-2">
{canVote && (
<div
className={cn(`flex items-center gap-2 pr-1`, {
"after:h-6 after:ml-1 after:w-[1px] after:bg-custom-border-200": canReact,
})}
>
<IssueVotes anchor={anchor.toString()} issueIdFromProps={issueId} size="sm" />
</div>
)}
{canReact && (
<div className="flex flex-wrap items-center gap-2">
<IssueEmojiReactions anchor={anchor.toString()} issueIdFromProps={issueId} size="sm" />
</div>
)}
</div>
</div>
);
});

View File

@@ -0,0 +1,110 @@
"use client";
import type { MutableRefObject } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation";
// plane types
import { Tooltip } from "@plane/propel/tooltip";
import type { IIssueDisplayProperties } from "@plane/types";
// plane ui
// plane utils
import { cn } from "@plane/utils";
// components
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/with-display-properties-HOC";
// helpers
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { usePublish } from "@/hooks/store/publish";
import { useIssueDetails } from "@/hooks/store/use-issue-details";
//
import type { IIssue } from "@/types/issue";
import { IssueProperties } from "../properties/all-properties";
import { getIssueBlockId } from "../utils";
import { BlockReactions } from "./block-reactions";
interface IssueBlockProps {
issueId: string;
groupId: string;
subGroupId: string;
displayProperties: IIssueDisplayProperties | undefined;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
}
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");
// hooks
const { setPeekId, getIsIssuePeeked, getIssueById } = useIssueDetails();
const handleIssuePeekOverview = () => {
setPeekId(issueId);
};
const { queryParam } = queryParamGenerator(board ? { board, peekId: issueId } : { peekId: issueId });
const issue = getIssueById(issueId);
if (!issue) return null;
return (
<div className={cn("group/kanban-block relative p-1.5")}>
<div
className={cn(
"relative 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) }
)}
>
<Link
id={getIssueBlockId(issueId, groupId, subGroupId)}
className="w-full"
href={`?${queryParam}`}
onClick={handleIssuePeekOverview}
>
<KanbanIssueDetailsBlock issue={issue} displayProperties={displayProperties} />
</Link>
<BlockReactions issueId={issueId} />
</div>
</div>
);
});
KanbanIssueBlock.displayName = "KanbanIssueBlock";

View File

@@ -0,0 +1,45 @@
import type { MutableRefObject } from "react";
import { observer } from "mobx-react";
//types
import type { 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}
</>
);
});

View File

@@ -0,0 +1,127 @@
import type { MutableRefObject } from "react";
import { isNil } from "lodash-es";
import { observer } from "mobx-react";
// types
import type {
GroupByColumnTypes,
IGroupByColumn,
TGroupedIssues,
IIssueDisplayProperties,
TSubGroupedIssues,
TIssueGroupByOptions,
TPaginationData,
TLoader,
} from "@plane/types";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member";
import { useModule } from "@/hooks/store/use-module";
import { useStates } from "@/hooks/store/use-state";
//
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 } => {
const groupVisibility = {
showGroup: true,
showIssues: true,
};
if (!showEmptyGroup) {
groupVisibility.showGroup = (getGroupIssueCount(_list.id, undefined, false) ?? 0) > 0;
}
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 as any}
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>
);
});

View File

@@ -0,0 +1,36 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
import { Circle } from "lucide-react";
// types
import type { 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>
</>
);
});

View File

@@ -0,0 +1,36 @@
import type { FC } from "react";
import React 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>
);
});

View File

@@ -0,0 +1,117 @@
"use client";
import type { MutableRefObject } from "react";
import { forwardRef, useCallback, useRef, useState } from "react";
import { observer } from "mobx-react";
//types
import type {
TGroupedIssues,
IIssueDisplayProperties,
TSubGroupedIssues,
TIssueGroupByOptions,
TPaginationData,
TLoader,
} from "@plane/types";
import { cn } from "@plane/utils";
// hooks
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
// local imports
import { KanbanIssueBlocksList } from "./blocks-list";
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 &darr;
</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>
);
});

View File

@@ -0,0 +1,298 @@
import type { MutableRefObject } from "react";
import { useState } from "react";
import { observer } from "mobx-react";
// types
import type {
GroupByColumnTypes,
IGroupByColumn,
TGroupedIssues,
IIssueDisplayProperties,
TSubGroupedIssues,
TIssueGroupByOptions,
TIssueOrderByOptions,
TPaginationData,
TLoader,
} from "@plane/types";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member";
import { useModule } from "@/hooks/store/use-module";
import { useStates } from "@/hooks/store/use-state";
//
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 as any}
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>
</>
);
});

View File

@@ -0,0 +1,63 @@
import { useCallback, useMemo } from "react";
import { observer } from "mobx-react";
// types
import type { IIssueDisplayProperties, TGroupedIssues } from "@plane/types";
// constants
// components
import { IssueLayoutHOC } from "@/components/issues/issue-layouts/issue-layout-HOC";
// hooks
import { useIssue } from "@/hooks/store/use-issue";
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);
},
[anchor, 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>
);
});

View File

@@ -0,0 +1,93 @@
"use client";
import { useRef } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams, useSearchParams } from "next/navigation";
// plane types
import { Tooltip } from "@plane/propel/tooltip";
import type { IIssueDisplayProperties } from "@plane/types";
// plane ui
// plane utils
import { cn } from "@plane/utils";
// helpers
import { queryParamGenerator } from "@/helpers/query-param-generator";
// hooks
import { usePublish } from "@/hooks/store/publish";
import { useIssueDetails } from "@/hooks/store/use-issue-details";
//
import { IssueProperties } from "../properties/all-properties";
interface IssueBlockProps {
issueId: string;
groupId: string;
displayProperties: IIssueDisplayProperties | undefined;
}
export const IssueBlock = observer((props: IssueBlockProps) => {
const { anchor } = useParams();
const { issueId, displayProperties } = props;
const searchParams = useSearchParams();
// 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 handleIssuePeekOverview = () => {
setPeekId(issueId);
};
const { queryParam } = queryParamGenerator(board ? { board, peekId: issueId } : { peekId: issueId });
const issue = getIssueById(issueId);
if (!issue) return null;
const projectIdentifier = project_details?.identifier;
return (
<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="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-start">
<p className="truncate">{issue.name}</p>
</Tooltip>
</Link>
</div>
</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>
</div>
);
});

View File

@@ -0,0 +1,25 @@
import type { FC, MutableRefObject } from "react";
// types
import type { 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>
);
};

View File

@@ -0,0 +1,90 @@
import { useRef } from "react";
import { observer } from "mobx-react";
// types
import type {
GroupByColumnTypes,
TGroupedIssues,
IIssueDisplayProperties,
TIssueGroupByOptions,
IGroupByColumn,
TPaginationData,
TLoader,
} from "@plane/types";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useLabel } from "@/hooks/store/use-label";
import { useMember } from "@/hooks/store/use-member";
import { useModule } from "@/hooks/store/use-module";
import { useStates } from "@/hooks/store/use-state";
//
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>
);
});

View File

@@ -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>
</>
);
});

View File

@@ -0,0 +1,140 @@
"use client";
import type { MutableRefObject } from "react";
import { Fragment, forwardRef, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
// plane types
import type {
IGroupByColumn,
TIssueGroupByOptions,
IIssueDisplayProperties,
TPaginationData,
TLoader,
} from "@plane/types";
// plane utils
import { cn } from "@plane/utils";
// 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);
// hooks
const { t } = useTranslation();
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)}
>
{t("common.load_more")} &darr;
</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;
});

View File

@@ -0,0 +1,181 @@
"use client";
import { observer } from "mobx-react";
import { Link, Paperclip } from "lucide-react";
import { ViewsIcon } from "@plane/propel/icons";
// plane imports
import { Tooltip } from "@plane/propel/tooltip";
import type { IIssueDisplayProperties } from "@plane/types";
import { cn } from "@plane/utils";
// components
import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/with-display-properties-HOC";
// helpers
import { getDate } from "@/helpers/date-time.helper";
//// hooks
import type { IIssue } from "@/types/issue";
import { IssueBlockCycle } from "./cycle";
import { IssueBlockDate } from "./due-date";
import { IssueBlockLabels } from "./labels";
import { IssueBlockMembers } from "./member";
import { IssueBlockModules } from "./modules";
import { IssueBlockPriority } from "./priority";
import { IssueBlockState } from "./state";
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-work items" 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,
}
)}
>
<ViewsIcon 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>
);
});

View File

@@ -0,0 +1,37 @@
"use client";
import { observer } from "mobx-react";
// plane ui
import { CycleIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
// plane utils
import { cn } from "@plane/utils";
//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">
<CycleIcon className="h-3 w-3 flex-shrink-0" />
<div className="max-w-40 flex-grow truncate ">{cycle?.name ?? "No Cycle"}</div>
</div>
</div>
</Tooltip>
);
});

View File

@@ -0,0 +1,41 @@
"use client";
import { observer } from "mobx-react";
import { CalendarCheck2 } from "lucide-react";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/utils";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
// hooks
import { useStates } from "@/hooks/store/use-state";
type Props = {
due_date: string | undefined;
stateId: string | undefined;
shouldHighLight?: boolean;
shouldShowBorder?: boolean;
};
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 (
<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>
);
});

View File

@@ -0,0 +1,72 @@
"use client";
import { observer } from "mobx-react";
import { Tags } from "lucide-react";
// plane imports
import { Tooltip } from "@plane/propel/tooltip";
// hooks
import { useLabel } from "@/hooks/store/use-label";
type Props = {
labelIds: string[];
shouldShowLabel?: boolean;
};
export const IssueBlockLabels = observer(({ labelIds, shouldShowLabel = false }: Props) => {
const { getLabelsByIds } = useLabel();
const labels = getLabelsByIds(labelIds);
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="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>
))}
</>
) : (
<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>
</Tooltip>
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,76 @@
"use client";
import { observer } from "mobx-react";
// icons
import type { LucideIcon } from "lucide-react";
import { Users } from "lucide-react";
// plane ui
import { Avatar, AvatarGroup } from "@plane/ui";
// plane utils
import { cn } from "@plane/utils";
// hooks
import { useMember } from "@/hooks/store/use-member";
//
import type { 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>
);
});

View File

@@ -0,0 +1,49 @@
"use client";
import { observer } from "mobx-react";
// plane ui
import { ModuleIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
// plane utils
import { cn } from "@plane/utils";
// 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">
<ModuleIcon 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>
);
});

View File

@@ -0,0 +1,68 @@
"use client";
import { SignalHigh } from "lucide-react";
import { useTranslation } from "@plane/i18n";
// types
import { PriorityIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import type { TIssuePriorities } from "@plane/types";
// constants
import { cn, getIssuePriorityFilters } from "@plane/utils";
export const IssueBlockPriority = ({
priority,
shouldShowName = false,
}: {
priority: TIssuePriorities | null;
shouldShowName?: boolean;
}) => {
// hooks
const { t } = useTranslation();
const priority_detail = priority != null ? getIssuePriorityFilters(priority) : null;
const priorityClasses = {
urgent: "bg-red-600/10 text-red-600 border-red-600 px-1",
high: "bg-orange-500/20 text-orange-950 border-orange-500",
medium: "bg-yellow-500/20 text-yellow-950 border-yellow-500",
low: "bg-custom-primary-100/20 text-custom-primary-950 border-custom-primary-100",
none: "hover:bg-custom-background-80 border-custom-border-300",
};
if (priority_detail === null) return <></>;
return (
<Tooltip tooltipHeading="Priority" tooltipContent={t(priority_detail?.titleTranslationKey || "")}>
<div
className={cn(
"h-full flex items-center gap-1.5 border-[0.5px] rounded text-xs px-2 py-0.5",
priorityClasses[priority ?? "none"],
{
// compact the icons if text is hidden
"px-0.5": !shouldShowName,
// highlight the whole button if text is hidden and priority is urgent
"bg-red-600/10 border-red-600": priority === "urgent" && shouldShowName,
}
)}
>
{priority ? (
<PriorityIcon
priority={priority}
size={12}
className={cn("flex-shrink-0", {
// increase the icon size if text is hidden
"h-3.5 w-3.5": !shouldShowName,
// centre align the icons if text is hidden
"translate-x-[0.0625rem]": !shouldShowName && priority === "high",
"translate-x-0.5": !shouldShowName && priority === "medium",
"translate-x-1": !shouldShowName && priority === "low",
// highlight the icon if priority is urgent
})}
/>
) : (
<SignalHigh className="size-3" />
)}
{shouldShowName && <span className="pl-2 text-sm">{t(priority_detail?.titleTranslationKey || "")}</span>}
</div>
</Tooltip>
);
};

View File

@@ -0,0 +1,35 @@
"use client";
import { observer } from "mobx-react";
// plane ui
import { StateGroupIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
// plane utils
import { cn } from "@plane/utils";
//hooks
import { useStates } from "@/hooks/store/use-state";
type Props = {
stateId: string | undefined;
shouldShowBorder?: boolean;
};
export const IssueBlockState = observer(({ stateId, shouldShowBorder = true }: Props) => {
const { getStateById } = useStates();
const state = getStateById(stateId);
return (
<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>
</Tooltip>
);
});

View File

@@ -0,0 +1,78 @@
"use client";
import type { FC } from "react";
import { useEffect } from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
// components
import { IssueAppliedFilters } from "@/components/issues/filters/applied-filters/root";
import { IssuePeekOverview } from "@/components/issues/peek-overview";
// hooks
import { useIssue } from "@/hooks/store/use-issue";
import { useIssueDetails } from "@/hooks/store/use-issue-details";
import { useIssueFilter } from "@/hooks/store/use-issue-filter";
// store
import type { PublishStore } from "@/store/publish/publish.store";
// local imports
import { SomethingWentWrongError } from "./error";
import { IssueKanbanLayoutRoot } from "./kanban/base-kanban-root";
import { IssuesListLayoutRoot } from "./list/base-list-root";
type Props = {
peekId: string | undefined;
publishSettings: PublishStore;
};
export const IssuesLayoutsRoot: FC<Props> = observer((props) => {
const { peekId, publishSettings } = props;
// store hooks
const { getIssueFilters } = useIssueFilter();
const { fetchPublicIssues } = useIssue();
const issueDetailStore = useIssueDetails();
// derived values
const { anchor } = publishSettings;
const issueFilters = anchor ? getIssueFilters(anchor) : undefined;
// derived values
const activeLayout = issueFilters?.display_filters?.layout || undefined;
const { error } = useSWR(
anchor ? `PUBLIC_ISSUES_${anchor}` : null,
anchor
? () => fetchPublicIssues(anchor, "init-loader", { groupedBy: "state", canGroup: true, perPageCount: 50 })
: null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
useEffect(() => {
if (peekId) {
issueDetailStore.setPeekId(peekId.toString());
}
}, [peekId, issueDetailStore]);
if (!anchor) return null;
if (error) return <SomethingWentWrongError />;
return (
<div className="relative h-full w-full overflow-hidden">
{peekId && <IssuePeekOverview anchor={anchor} peekId={peekId} />}
{activeLayout && (
<div className="relative flex h-full w-full flex-col overflow-hidden">
{/* applied filters */}
<IssueAppliedFilters anchor={anchor} />
{activeLayout === "list" && (
<div className="relative h-full w-full overflow-y-auto">
<IssuesListLayoutRoot anchor={anchor} />
</div>
)}
{activeLayout === "kanban" && (
<div className="relative mx-auto h-full w-full p-5">
<IssueKanbanLayoutRoot anchor={anchor} />
</div>
)}
</div>
)}
</div>
);
});

View File

@@ -0,0 +1,240 @@
"use client";
import { isNil } from "lodash-es";
// types
import { EIconSize, ISSUE_PRIORITIES } from "@plane/constants";
import { CycleGroupIcon, CycleIcon, ModuleIcon, PriorityIcon, StateGroupIcon } from "@plane/propel/icons";
import type {
GroupByColumnTypes,
IGroupByColumn,
TCycleGroups,
IIssueDisplayProperties,
TGroupedIssues,
} from "@plane/types";
// ui
import { Avatar } from "@plane/ui";
// components
// constants
// stores
import type { ICycleStore } from "@/store/cycle.store";
import type { IIssueLabelStore } from "@/store/label.store";
import type { IIssueMemberStore } from "@/store/members.store";
import type { IIssueModuleStore } from "@/store/module.store";
import type { 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 work items`, 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: <CycleIcon 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: <ModuleIcon className="h-3.5 w-3.5" />,
payload: { module_ids: [moduleInfo.id] },
});
}) as any;
moduleGroups.push({
id: "None",
name: "None",
icon: <ModuleIcon className="h-3.5 w-3.5" />,
payload: { module_ids: [] },
});
return moduleGroups as any;
};
const getStateColumns = (projectState: IStateStore): IGroupByColumn[] | undefined => {
const { sortedStates } = projectState;
if (!sortedStates) return;
return sortedStates.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} size={EIconSize.MD} />
</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;
};

View File

@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
import { observer } from "mobx-react";
// plane imports
import type { 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}</>;
}
);