[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:
rahulramesha 2024-07-22 16:01:46 +05:30 committed by GitHub
parent 2ee6cd20d8
commit 8577a56068
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 2772 additions and 484 deletions

View file

@ -202,4 +202,6 @@ export interface IssuePaginationOptions {
before?: string;
after?: string;
groupedBy?: TIssueGroupByOptions;
subGroupedBy?: TIssueGroupByOptions;
orderBy?: TIssueOrderByOptions;
}

View file

@ -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";

View 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;

View 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;

View 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 />;

View 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) => <></>;

View file

@ -0,0 +1 @@
export * from "./use-published-view";

View file

@ -0,0 +1,5 @@
export const useView = () => ({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
fetchViewDetails: (anchor: string) => {},
viewData: {},
});

View file

@ -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";

View file

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

View file

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

View file

@ -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";

View file

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

View file

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

View 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>
);
});

View file

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

View file

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

View file

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

View file

@ -1,3 +1,2 @@
export * from "./block";
export * from "./header";
export * from "./root";
export * from "./blocks-list";

View file

@ -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 &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

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

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

View file

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

View file

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

View file

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

View 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>
);
});

View file

@ -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 &darr;
</div>
)
)}
</div>
) : (
<div className="bg-custom-background-100 p-3 text-sm text-custom-text-400">No issues.</div>
)}
</div>
);
});

View file

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

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

@ -1,3 +1,2 @@
export * from "./block";
export * from "./header";
export * from "./root";
export * from "./blocks-list";

View 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 &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

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

View file

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

View file

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

View file

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

View file

@ -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";

View file

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

View file

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

View file

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

View file

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

View file

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

View 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;
};

View file

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

View file

@ -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,

View 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>
);

View file

@ -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" },
];

View file

@ -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";

View 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;
};

View 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;
};

View 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;
};

View 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;
});
}
}

View 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;
});
}
}

View 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;
});
}
}

View 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;
};
}

View file

@ -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

View file

@ -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`;

View file

@ -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;

View file

@ -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,

View 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 [];
}
};
}

View 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 [];
}
};
}

View file

@ -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
View file

@ -0,0 +1,5 @@
export type TPublicCycle = {
id: string;
name: string;
status: string;
};

View file

@ -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
View 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
View file

@ -0,0 +1,4 @@
export type TPublicModule = {
id: string;
name: string;
};

View file

@ -0,0 +1 @@
export * from "ce/components/issue-layouts/root";

View file

@ -0,0 +1 @@
export * from "ce/components/navbar";

View file

@ -0,0 +1 @@
export * from "ce/hooks/store";

View file

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

View file

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

View file

@ -0,0 +1,2 @@
export * from "./modal";
export * from "./use-view-publish";

View 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) => <></>;

View 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,
});

View file

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

View file

@ -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) => {

View file

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

View file

@ -0,0 +1 @@
export * from "ce/components/views/publish";