forked from github/plane
chore: virtualization ish behaviour for issue layouts (#3538)
* Virtualization like core changes with intersection observer * Virtualization like changes for spreadsheet * Virtualization like changes for list * Virtualization like changes for kanban * add logic to render all the issues at once * revert back the changes for list to follow the old pattern of grouping * fix column shadow in spreadsheet for rendering rows * fix constant draggable height while dragging and rendering blocks in kanban * fix height glitch while rendered rows adjust to default height * remove loading animation for issue layouts * reduce requestIdleCallback timer to 300ms * remove logic for index tarcking to force render as the same effect seems to be achieved by removing requestIdleCallback * Fix Kanban droppable height * fix spreadsheet sub issue loading * force change in reference to re render the render if visible component when the order of list changes * add comments and minor changes
This commit is contained in:
parent
eb4c3a4db5
commit
e2affc3fa6
9
packages/types/src/issues.d.ts
vendored
9
packages/types/src/issues.d.ts
vendored
@ -221,3 +221,12 @@ export interface IGroupByColumn {
|
|||||||
export interface IIssueMap {
|
export interface IIssueMap {
|
||||||
[key: string]: TIssue;
|
[key: string]: TIssue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IIssueListRow {
|
||||||
|
id: string;
|
||||||
|
groupId: string;
|
||||||
|
type: "HEADER" | "NO_ISSUES" | "QUICK_ADD" | "ISSUE";
|
||||||
|
name?: string;
|
||||||
|
icon?: ReactElement | undefined;
|
||||||
|
payload?: Partial<TIssue>;
|
||||||
|
}
|
||||||
|
80
web/components/core/render-if-visible-HOC.tsx
Normal file
80
web/components/core/render-if-visible-HOC.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { cn } from "helpers/common.helper";
|
||||||
|
import React, { useState, useRef, useEffect, ReactNode, MutableRefObject } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
defaultHeight?: string;
|
||||||
|
verticalOffset?: number;
|
||||||
|
horizonatlOffset?: number;
|
||||||
|
root?: MutableRefObject<HTMLElement | null>;
|
||||||
|
children: ReactNode;
|
||||||
|
as?: keyof JSX.IntrinsicElements;
|
||||||
|
classNames?: string;
|
||||||
|
alwaysRender?: boolean;
|
||||||
|
placeholderChildren?: ReactNode;
|
||||||
|
pauseHeightUpdateWhileRendering?: boolean;
|
||||||
|
changingReference?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RenderIfVisible: React.FC<Props> = (props) => {
|
||||||
|
const {
|
||||||
|
defaultHeight = "300px",
|
||||||
|
root,
|
||||||
|
verticalOffset = 50,
|
||||||
|
horizonatlOffset = 0,
|
||||||
|
as = "div",
|
||||||
|
children,
|
||||||
|
classNames = "",
|
||||||
|
alwaysRender = false, //render the children even if it is not visble in root
|
||||||
|
placeholderChildren = null, //placeholder children
|
||||||
|
pauseHeightUpdateWhileRendering = false, //while this is true the height of the blocks are maintained
|
||||||
|
changingReference, //This is to force render when this reference is changed
|
||||||
|
} = props;
|
||||||
|
const [shouldVisible, setShouldVisible] = useState<boolean>(alwaysRender);
|
||||||
|
const placeholderHeight = useRef<string>(defaultHeight);
|
||||||
|
const intersectionRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const isVisible = alwaysRender || shouldVisible;
|
||||||
|
|
||||||
|
// Set visibility with intersection observer
|
||||||
|
useEffect(() => {
|
||||||
|
if (intersectionRef.current) {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (typeof window !== undefined && window.requestIdleCallback) {
|
||||||
|
window.requestIdleCallback(() => setShouldVisible(entries[0].isIntersecting), {
|
||||||
|
timeout: 300,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setShouldVisible(entries[0].isIntersecting);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: root?.current,
|
||||||
|
rootMargin: `${verticalOffset}% ${horizonatlOffset}% ${verticalOffset}% ${horizonatlOffset}%`,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
observer.observe(intersectionRef.current);
|
||||||
|
return () => {
|
||||||
|
if (intersectionRef.current) {
|
||||||
|
observer.unobserve(intersectionRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [root?.current, intersectionRef, children, changingReference]);
|
||||||
|
|
||||||
|
//Set height after render
|
||||||
|
useEffect(() => {
|
||||||
|
if (intersectionRef.current && isVisible) {
|
||||||
|
placeholderHeight.current = `${intersectionRef.current.offsetHeight}px`;
|
||||||
|
}
|
||||||
|
}, [isVisible, intersectionRef, alwaysRender, pauseHeightUpdateWhileRendering]);
|
||||||
|
|
||||||
|
const child = isVisible ? <>{children}</> : placeholderChildren;
|
||||||
|
const style =
|
||||||
|
isVisible && !pauseHeightUpdateWhileRendering ? {} : { height: placeholderHeight.current, width: "100%" };
|
||||||
|
const className = isVisible ? classNames : cn(classNames, "bg-custom-background-80");
|
||||||
|
|
||||||
|
return React.createElement(as, { ref: intersectionRef, style, className }, child);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RenderIfVisible;
|
@ -1,4 +1,4 @@
|
|||||||
import { FC, useCallback, useState } from "react";
|
import { FC, useCallback, useRef, useState } from "react";
|
||||||
import { DragDropContext, DragStart, DraggableLocation, DropResult, Droppable } from "@hello-pangea/dnd";
|
import { DragDropContext, DragStart, DraggableLocation, DropResult, Droppable } from "@hello-pangea/dnd";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
@ -94,6 +94,8 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
|||||||
|
|
||||||
const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issues?.viewFlags || {};
|
const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issues?.viewFlags || {};
|
||||||
|
|
||||||
|
const scrollableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// states
|
// states
|
||||||
const [isDragStarted, setIsDragStarted] = useState<boolean>(false);
|
const [isDragStarted, setIsDragStarted] = useState<boolean>(false);
|
||||||
const [dragState, setDragState] = useState<KanbanDragState>({});
|
const [dragState, setDragState] = useState<KanbanDragState>({});
|
||||||
@ -245,7 +247,10 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="horizontal-scroll-enable relative h-full w-full overflow-auto bg-custom-background-90">
|
<div
|
||||||
|
className="flex horizontal-scroll-enable relative h-full w-full overflow-auto bg-custom-background-90"
|
||||||
|
ref={scrollableContainerRef}
|
||||||
|
>
|
||||||
<div className="relative h-max w-max min-w-full bg-custom-background-90 px-2">
|
<div className="relative h-max w-max min-w-full bg-custom-background-90 px-2">
|
||||||
<DragDropContext onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
<DragDropContext onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
||||||
{/* drag and delete component */}
|
{/* drag and delete component */}
|
||||||
@ -289,6 +294,8 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
|||||||
canEditProperties={canEditProperties}
|
canEditProperties={canEditProperties}
|
||||||
storeType={storeType}
|
storeType={storeType}
|
||||||
addIssuesToView={addIssuesToView}
|
addIssuesToView={addIssuesToView}
|
||||||
|
scrollableContainerRef={scrollableContainerRef}
|
||||||
|
isDragStarted={isDragStarted}
|
||||||
/>
|
/>
|
||||||
</DragDropContext>
|
</DragDropContext>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { memo } from "react";
|
import { MutableRefObject, memo } from "react";
|
||||||
import { Draggable, DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd";
|
import { Draggable, DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// hooks
|
// hooks
|
||||||
@ -13,6 +13,7 @@ import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types";
|
|||||||
import { EIssueActions } from "../types";
|
import { EIssueActions } from "../types";
|
||||||
// helper
|
// helper
|
||||||
import { cn } from "helpers/common.helper";
|
import { cn } from "helpers/common.helper";
|
||||||
|
import RenderIfVisible from "components/core/render-if-visible-HOC";
|
||||||
|
|
||||||
interface IssueBlockProps {
|
interface IssueBlockProps {
|
||||||
peekIssueId?: string;
|
peekIssueId?: string;
|
||||||
@ -25,6 +26,9 @@ interface IssueBlockProps {
|
|||||||
handleIssues: (issue: TIssue, action: EIssueActions) => void;
|
handleIssues: (issue: TIssue, action: EIssueActions) => void;
|
||||||
quickActions: (issue: TIssue) => React.ReactNode;
|
quickActions: (issue: TIssue) => React.ReactNode;
|
||||||
canEditProperties: (projectId: string | undefined) => boolean;
|
canEditProperties: (projectId: string | undefined) => boolean;
|
||||||
|
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||||
|
isDragStarted?: boolean;
|
||||||
|
issueIds: string[]; //DO NOT REMOVE< needed to force render for virtualization
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IssueDetailsBlockProps {
|
interface IssueDetailsBlockProps {
|
||||||
@ -107,6 +111,9 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
|
|||||||
handleIssues,
|
handleIssues,
|
||||||
quickActions,
|
quickActions,
|
||||||
canEditProperties,
|
canEditProperties,
|
||||||
|
scrollableContainerRef,
|
||||||
|
isDragStarted,
|
||||||
|
issueIds,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const issue = issuesMap[issueId];
|
const issue = issuesMap[issueId];
|
||||||
@ -129,24 +136,31 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
|
|||||||
{...provided.dragHandleProps}
|
{...provided.dragHandleProps}
|
||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
>
|
>
|
||||||
{issue.tempId !== undefined && (
|
|
||||||
<div className="absolute left-0 top-0 z-[99999] h-full w-full animate-pulse bg-custom-background-100/20" />
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"space-y-2 rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 px-3 py-2 text-sm transition-all hover:border-custom-border-400",
|
"rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 px-3 py-2 text-sm transition-all hover:border-custom-border-400",
|
||||||
{ "hover:cursor-grab": !isDragDisabled },
|
{ "hover:cursor-grab": !isDragDisabled },
|
||||||
{ "border-custom-primary-100": snapshot.isDragging },
|
{ "border-custom-primary-100": snapshot.isDragging },
|
||||||
{ "border border-custom-primary-70 hover:border-custom-primary-70": peekIssueId === issue.id }
|
{ "border border-custom-primary-70 hover:border-custom-primary-70": peekIssueId === issue.id }
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<KanbanIssueDetailsBlock
|
<RenderIfVisible
|
||||||
issue={issue}
|
classNames="space-y-2"
|
||||||
displayProperties={displayProperties}
|
root={scrollableContainerRef}
|
||||||
handleIssues={handleIssues}
|
defaultHeight="100px"
|
||||||
quickActions={quickActions}
|
horizonatlOffset={50}
|
||||||
isReadOnly={!canEditIssueProperties}
|
alwaysRender={snapshot.isDragging}
|
||||||
/>
|
pauseHeightUpdateWhileRendering={isDragStarted}
|
||||||
|
changingReference={issueIds}
|
||||||
|
>
|
||||||
|
<KanbanIssueDetailsBlock
|
||||||
|
issue={issue}
|
||||||
|
displayProperties={displayProperties}
|
||||||
|
handleIssues={handleIssues}
|
||||||
|
quickActions={quickActions}
|
||||||
|
isReadOnly={!canEditIssueProperties}
|
||||||
|
/>
|
||||||
|
</RenderIfVisible>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { memo } from "react";
|
import { MutableRefObject, memo } from "react";
|
||||||
//types
|
//types
|
||||||
import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types";
|
import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types";
|
||||||
import { EIssueActions } from "../types";
|
import { EIssueActions } from "../types";
|
||||||
@ -16,6 +16,8 @@ interface IssueBlocksListProps {
|
|||||||
handleIssues: (issue: TIssue, action: EIssueActions) => void;
|
handleIssues: (issue: TIssue, action: EIssueActions) => void;
|
||||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||||
canEditProperties: (projectId: string | undefined) => boolean;
|
canEditProperties: (projectId: string | undefined) => boolean;
|
||||||
|
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||||
|
isDragStarted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (props) => {
|
const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (props) => {
|
||||||
@ -30,6 +32,8 @@ const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (props) => {
|
|||||||
handleIssues,
|
handleIssues,
|
||||||
quickActions,
|
quickActions,
|
||||||
canEditProperties,
|
canEditProperties,
|
||||||
|
scrollableContainerRef,
|
||||||
|
isDragStarted,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -56,6 +60,9 @@ const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (props) => {
|
|||||||
index={index}
|
index={index}
|
||||||
isDragDisabled={isDragDisabled}
|
isDragDisabled={isDragDisabled}
|
||||||
canEditProperties={canEditProperties}
|
canEditProperties={canEditProperties}
|
||||||
|
scrollableContainerRef={scrollableContainerRef}
|
||||||
|
isDragStarted={isDragStarted}
|
||||||
|
issueIds={issueIds} //passing to force render for virtualization whenever parent rerenders
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
@ -20,6 +20,7 @@ import {
|
|||||||
import { EIssueActions } from "../types";
|
import { EIssueActions } from "../types";
|
||||||
import { getGroupByColumns } from "../utils";
|
import { getGroupByColumns } from "../utils";
|
||||||
import { TCreateModalStoreTypes } from "constants/issue";
|
import { TCreateModalStoreTypes } from "constants/issue";
|
||||||
|
import { MutableRefObject } from "react";
|
||||||
|
|
||||||
export interface IGroupByKanBan {
|
export interface IGroupByKanBan {
|
||||||
issuesMap: IIssueMap;
|
issuesMap: IIssueMap;
|
||||||
@ -45,6 +46,8 @@ export interface IGroupByKanBan {
|
|||||||
storeType?: TCreateModalStoreTypes;
|
storeType?: TCreateModalStoreTypes;
|
||||||
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
|
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
|
||||||
canEditProperties: (projectId: string | undefined) => boolean;
|
canEditProperties: (projectId: string | undefined) => boolean;
|
||||||
|
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||||
|
isDragStarted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||||
@ -67,6 +70,8 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
|||||||
storeType,
|
storeType,
|
||||||
addIssuesToView,
|
addIssuesToView,
|
||||||
canEditProperties,
|
canEditProperties,
|
||||||
|
scrollableContainerRef,
|
||||||
|
isDragStarted,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const member = useMember();
|
const member = useMember();
|
||||||
@ -92,11 +97,7 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
|||||||
const groupByVisibilityToggle = visibilityGroupBy(_list);
|
const groupByVisibilityToggle = visibilityGroupBy(_list);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={`relative flex flex-shrink-0 flex-col group ${groupByVisibilityToggle ? `` : `w-[340px]`}`}>
|
||||||
className={`relative flex flex-shrink-0 flex-col h-full group ${
|
|
||||||
groupByVisibilityToggle ? `` : `w-[340px]`
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{sub_group_by === null && (
|
{sub_group_by === null && (
|
||||||
<div className="flex-shrink-0 sticky top-0 z-[2] w-full bg-custom-background-90 py-1">
|
<div className="flex-shrink-0 sticky top-0 z-[2] w-full bg-custom-background-90 py-1">
|
||||||
<HeaderGroupByCard
|
<HeaderGroupByCard
|
||||||
@ -135,6 +136,8 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
|||||||
disableIssueCreation={disableIssueCreation}
|
disableIssueCreation={disableIssueCreation}
|
||||||
canEditProperties={canEditProperties}
|
canEditProperties={canEditProperties}
|
||||||
groupByVisibilityToggle={groupByVisibilityToggle}
|
groupByVisibilityToggle={groupByVisibilityToggle}
|
||||||
|
scrollableContainerRef={scrollableContainerRef}
|
||||||
|
isDragStarted={isDragStarted}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -168,6 +171,8 @@ export interface IKanBan {
|
|||||||
storeType?: TCreateModalStoreTypes;
|
storeType?: TCreateModalStoreTypes;
|
||||||
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
|
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
|
||||||
canEditProperties: (projectId: string | undefined) => boolean;
|
canEditProperties: (projectId: string | undefined) => boolean;
|
||||||
|
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||||
|
isDragStarted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const KanBan: React.FC<IKanBan> = observer((props) => {
|
export const KanBan: React.FC<IKanBan> = observer((props) => {
|
||||||
@ -189,6 +194,8 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
|
|||||||
storeType,
|
storeType,
|
||||||
addIssuesToView,
|
addIssuesToView,
|
||||||
canEditProperties,
|
canEditProperties,
|
||||||
|
scrollableContainerRef,
|
||||||
|
isDragStarted,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const issueKanBanView = useKanbanView();
|
const issueKanBanView = useKanbanView();
|
||||||
@ -213,6 +220,8 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
|
|||||||
storeType={storeType}
|
storeType={storeType}
|
||||||
addIssuesToView={addIssuesToView}
|
addIssuesToView={addIssuesToView}
|
||||||
canEditProperties={canEditProperties}
|
canEditProperties={canEditProperties}
|
||||||
|
scrollableContainerRef={scrollableContainerRef}
|
||||||
|
isDragStarted={isDragStarted}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { MutableRefObject } from "react";
|
||||||
import { Droppable } from "@hello-pangea/dnd";
|
import { Droppable } from "@hello-pangea/dnd";
|
||||||
// hooks
|
// hooks
|
||||||
import { useProjectState } from "hooks/store";
|
import { useProjectState } from "hooks/store";
|
||||||
@ -37,6 +38,8 @@ interface IKanbanGroup {
|
|||||||
disableIssueCreation?: boolean;
|
disableIssueCreation?: boolean;
|
||||||
canEditProperties: (projectId: string | undefined) => boolean;
|
canEditProperties: (projectId: string | undefined) => boolean;
|
||||||
groupByVisibilityToggle: boolean;
|
groupByVisibilityToggle: boolean;
|
||||||
|
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||||
|
isDragStarted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const KanbanGroup = (props: IKanbanGroup) => {
|
export const KanbanGroup = (props: IKanbanGroup) => {
|
||||||
@ -57,6 +60,8 @@ export const KanbanGroup = (props: IKanbanGroup) => {
|
|||||||
disableIssueCreation,
|
disableIssueCreation,
|
||||||
quickAddCallback,
|
quickAddCallback,
|
||||||
viewId,
|
viewId,
|
||||||
|
scrollableContainerRef,
|
||||||
|
isDragStarted,
|
||||||
} = props;
|
} = props;
|
||||||
// hooks
|
// hooks
|
||||||
const projectState = useProjectState();
|
const projectState = useProjectState();
|
||||||
@ -127,6 +132,8 @@ export const KanbanGroup = (props: IKanbanGroup) => {
|
|||||||
handleIssues={handleIssues}
|
handleIssues={handleIssues}
|
||||||
quickActions={quickActions}
|
quickActions={quickActions}
|
||||||
canEditProperties={canEditProperties}
|
canEditProperties={canEditProperties}
|
||||||
|
scrollableContainerRef={scrollableContainerRef}
|
||||||
|
isDragStarted={isDragStarted}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{provided.placeholder}
|
{provided.placeholder}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { MutableRefObject } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// components
|
// components
|
||||||
import { KanBan } from "./default";
|
import { KanBan } from "./default";
|
||||||
@ -80,6 +81,7 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader {
|
|||||||
viewId?: string
|
viewId?: string
|
||||||
) => Promise<TIssue | undefined>;
|
) => Promise<TIssue | undefined>;
|
||||||
viewId?: string;
|
viewId?: string;
|
||||||
|
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||||
}
|
}
|
||||||
const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
||||||
const {
|
const {
|
||||||
@ -99,6 +101,8 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
|||||||
addIssuesToView,
|
addIssuesToView,
|
||||||
quickAddCallback,
|
quickAddCallback,
|
||||||
viewId,
|
viewId,
|
||||||
|
scrollableContainerRef,
|
||||||
|
isDragStarted,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const calculateIssueCount = (column_id: string) => {
|
const calculateIssueCount = (column_id: string) => {
|
||||||
@ -150,6 +154,8 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
|||||||
addIssuesToView={addIssuesToView}
|
addIssuesToView={addIssuesToView}
|
||||||
quickAddCallback={quickAddCallback}
|
quickAddCallback={quickAddCallback}
|
||||||
viewId={viewId}
|
viewId={viewId}
|
||||||
|
scrollableContainerRef={scrollableContainerRef}
|
||||||
|
isDragStarted={isDragStarted}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -183,6 +189,7 @@ export interface IKanBanSwimLanes {
|
|||||||
) => Promise<TIssue | undefined>;
|
) => Promise<TIssue | undefined>;
|
||||||
viewId?: string;
|
viewId?: string;
|
||||||
canEditProperties: (projectId: string | undefined) => boolean;
|
canEditProperties: (projectId: string | undefined) => boolean;
|
||||||
|
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
||||||
@ -204,6 +211,7 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
|||||||
addIssuesToView,
|
addIssuesToView,
|
||||||
quickAddCallback,
|
quickAddCallback,
|
||||||
viewId,
|
viewId,
|
||||||
|
scrollableContainerRef,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const member = useMember();
|
const member = useMember();
|
||||||
@ -249,6 +257,7 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
|||||||
canEditProperties={canEditProperties}
|
canEditProperties={canEditProperties}
|
||||||
quickAddCallback={quickAddCallback}
|
quickAddCallback={quickAddCallback}
|
||||||
viewId={viewId}
|
viewId={viewId}
|
||||||
|
scrollableContainerRef={scrollableContainerRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
@ -122,26 +122,24 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className={`relative h-full w-full bg-custom-background-90`}>
|
||||||
<div className={`relative h-full w-full bg-custom-background-90`}>
|
<List
|
||||||
<List
|
issuesMap={issueMap}
|
||||||
issuesMap={issueMap}
|
displayProperties={displayProperties}
|
||||||
displayProperties={displayProperties}
|
group_by={group_by}
|
||||||
group_by={group_by}
|
handleIssues={handleIssues}
|
||||||
handleIssues={handleIssues}
|
quickActions={renderQuickActions}
|
||||||
quickActions={renderQuickActions}
|
issueIds={issueIds}
|
||||||
issueIds={issueIds}
|
showEmptyGroup={showEmptyGroup}
|
||||||
showEmptyGroup={showEmptyGroup}
|
viewId={viewId}
|
||||||
viewId={viewId}
|
quickAddCallback={issues?.quickAddIssue}
|
||||||
quickAddCallback={issues?.quickAddIssue}
|
enableIssueQuickAdd={!!enableQuickAdd}
|
||||||
enableIssueQuickAdd={!!enableQuickAdd}
|
canEditProperties={canEditProperties}
|
||||||
canEditProperties={canEditProperties}
|
disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
|
||||||
disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
|
storeType={storeType}
|
||||||
storeType={storeType}
|
addIssuesToView={addIssuesToView}
|
||||||
addIssuesToView={addIssuesToView}
|
isCompletedCycle={isCompletedCycle}
|
||||||
isCompletedCycle={isCompletedCycle}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -48,64 +48,59 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
|
|||||||
const projectDetails = getProjectById(issue.project_id);
|
const projectDetails = getProjectById(issue.project_id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
<div
|
className={cn("min-h-12 relative flex items-center gap-3 bg-custom-background-100 p-3 text-sm", {
|
||||||
className={cn(
|
"border border-custom-primary-70 hover:border-custom-primary-70":
|
||||||
"relative flex items-center gap-3 bg-custom-background-100 p-3 text-sm border border-transparent border-b-custom-border-200",
|
|
||||||
{
|
|
||||||
"border border-custom-primary-70 hover:border-custom-primary-70":
|
|
||||||
peekIssue && peekIssue.issueId === issue.id,
|
peekIssue && peekIssue.issueId === issue.id,
|
||||||
"last:border-b-transparent": peekIssue?.issueId !== issue.id,
|
"last:border-b-transparent": peekIssue?.issueId !== issue.id
|
||||||
}
|
})}
|
||||||
)}
|
>
|
||||||
>
|
{displayProperties && displayProperties?.key && (
|
||||||
{displayProperties && displayProperties?.key && (
|
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300">
|
||||||
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300">
|
{projectDetails?.identifier}-{issue.sequence_id}
|
||||||
{projectDetails?.identifier}-{issue.sequence_id}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{issue?.tempId !== undefined && (
|
{issue?.tempId !== undefined && (
|
||||||
<div className="absolute left-0 top-0 z-[99999] h-full w-full animate-pulse bg-custom-background-100/20" />
|
<div className="absolute left-0 top-0 z-[99999] h-full w-full animate-pulse bg-custom-background-100/20" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{issue?.is_draft ? (
|
{issue?.is_draft ? (
|
||||||
|
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
||||||
|
<span>{issue.name}</span>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<ControlLink
|
||||||
|
href={`/${workspaceSlug}/projects/${projectId}/issues/${issueId}`}
|
||||||
|
target="_blank"
|
||||||
|
onClick={() => handleIssuePeekOverview(issue)}
|
||||||
|
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
|
||||||
|
>
|
||||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
||||||
<span>{issue.name}</span>
|
<span>{issue.name}</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
</ControlLink>
|
||||||
<ControlLink
|
)}
|
||||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issueId}`}
|
|
||||||
target="_blank"
|
|
||||||
onClick={() => handleIssuePeekOverview(issue)}
|
|
||||||
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
|
|
||||||
>
|
|
||||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
|
||||||
<span>{issue.name}</span>
|
|
||||||
</Tooltip>
|
|
||||||
</ControlLink>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="ml-auto flex flex-shrink-0 items-center gap-2">
|
<div className="ml-auto flex flex-shrink-0 items-center gap-2">
|
||||||
{!issue?.tempId ? (
|
{!issue?.tempId ? (
|
||||||
<>
|
<>
|
||||||
<IssueProperties
|
<IssueProperties
|
||||||
className="relative flex items-center gap-2 whitespace-nowrap"
|
className="relative flex items-center gap-2 whitespace-nowrap"
|
||||||
issue={issue}
|
issue={issue}
|
||||||
isReadOnly={!canEditIssueProperties}
|
isReadOnly={!canEditIssueProperties}
|
||||||
handleIssues={updateIssue}
|
handleIssues={updateIssue}
|
||||||
displayProperties={displayProperties}
|
displayProperties={displayProperties}
|
||||||
activeLayout="List"
|
activeLayout="List"
|
||||||
/>
|
/>
|
||||||
{quickActions(issue)}
|
{quickActions(issue)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-4 w-4">
|
<div className="h-4 w-4">
|
||||||
<Spinner className="h-4 w-4" />
|
<Spinner className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { FC } from "react";
|
import { FC, MutableRefObject } from "react";
|
||||||
// components
|
// components
|
||||||
import { IssueBlock } from "components/issues";
|
import { IssueBlock } from "components/issues";
|
||||||
// types
|
// types
|
||||||
import { TGroupedIssues, TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types";
|
import { TGroupedIssues, TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types";
|
||||||
import { EIssueActions } from "../types";
|
import { EIssueActions } from "../types";
|
||||||
|
import RenderIfVisible from "components/core/render-if-visible-HOC";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
issueIds: TGroupedIssues | TUnGroupedIssues | any;
|
issueIds: TGroupedIssues | TUnGroupedIssues | any;
|
||||||
@ -12,27 +13,34 @@ interface Props {
|
|||||||
handleIssues: (issue: TIssue, action: EIssueActions) => Promise<void>;
|
handleIssues: (issue: TIssue, action: EIssueActions) => Promise<void>;
|
||||||
quickActions: (issue: TIssue) => React.ReactNode;
|
quickActions: (issue: TIssue) => React.ReactNode;
|
||||||
displayProperties: IIssueDisplayProperties | undefined;
|
displayProperties: IIssueDisplayProperties | undefined;
|
||||||
|
containerRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const IssueBlocksList: FC<Props> = (props) => {
|
export const IssueBlocksList: FC<Props> = (props) => {
|
||||||
const { issueIds, issuesMap, handleIssues, quickActions, displayProperties, canEditProperties } = props;
|
const { issueIds, issuesMap, handleIssues, quickActions, displayProperties, canEditProperties, containerRef } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
{issueIds && issueIds.length > 0 ? (
|
{issueIds && issueIds.length > 0 ? (
|
||||||
issueIds.map((issueId: string) => {
|
issueIds.map((issueId: string) => {
|
||||||
if (!issueId) return null;
|
if (!issueId) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IssueBlock
|
<RenderIfVisible
|
||||||
key={issueId}
|
key={`${issueId}`}
|
||||||
issueId={issueId}
|
defaultHeight="3rem"
|
||||||
issuesMap={issuesMap}
|
root={containerRef}
|
||||||
handleIssues={handleIssues}
|
classNames={"relative border border-transparent border-b-custom-border-200 last:border-b-transparent"}
|
||||||
quickActions={quickActions}
|
changingReference={issueIds}
|
||||||
canEditProperties={canEditProperties}
|
>
|
||||||
displayProperties={displayProperties}
|
<IssueBlock
|
||||||
/>
|
issueId={issueId}
|
||||||
|
issuesMap={issuesMap}
|
||||||
|
handleIssues={handleIssues}
|
||||||
|
quickActions={quickActions}
|
||||||
|
canEditProperties={canEditProperties}
|
||||||
|
displayProperties={displayProperties}
|
||||||
|
/>
|
||||||
|
</RenderIfVisible>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
) : (
|
) : (
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
// components
|
// components
|
||||||
import { IssueBlocksList, ListQuickAddIssueForm } from "components/issues";
|
import { IssueBlocksList, ListQuickAddIssueForm } from "components/issues";
|
||||||
|
import { HeaderGroupByCard } from "./headers/group-by-card";
|
||||||
// hooks
|
// hooks
|
||||||
import { useLabel, useMember, useProject, useProjectState } from "hooks/store";
|
import { useLabel, useMember, useProject, useProjectState } from "hooks/store";
|
||||||
// types
|
// types
|
||||||
@ -10,12 +12,12 @@ import {
|
|||||||
IIssueDisplayProperties,
|
IIssueDisplayProperties,
|
||||||
TIssueMap,
|
TIssueMap,
|
||||||
TUnGroupedIssues,
|
TUnGroupedIssues,
|
||||||
|
IGroupByColumn,
|
||||||
} from "@plane/types";
|
} from "@plane/types";
|
||||||
import { EIssueActions } from "../types";
|
import { EIssueActions } from "../types";
|
||||||
// constants
|
// constants
|
||||||
import { HeaderGroupByCard } from "./headers/group-by-card";
|
|
||||||
import { getGroupByColumns } from "../utils";
|
|
||||||
import { TCreateModalStoreTypes } from "constants/issue";
|
import { TCreateModalStoreTypes } from "constants/issue";
|
||||||
|
import { getGroupByColumns } from "../utils";
|
||||||
|
|
||||||
export interface IGroupByList {
|
export interface IGroupByList {
|
||||||
issueIds: TGroupedIssues | TUnGroupedIssues | any;
|
issueIds: TGroupedIssues | TUnGroupedIssues | any;
|
||||||
@ -64,9 +66,11 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
|
|||||||
const label = useLabel();
|
const label = useLabel();
|
||||||
const projectState = useProjectState();
|
const projectState = useProjectState();
|
||||||
|
|
||||||
const list = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member, true);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
if (!list) return null;
|
const groups = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member, true);
|
||||||
|
|
||||||
|
if (!groups) return null;
|
||||||
|
|
||||||
const prePopulateQuickAddData = (groupByKey: string | null, value: any) => {
|
const prePopulateQuickAddData = (groupByKey: string | null, value: any) => {
|
||||||
const defaultState = projectState.projectStates?.find((state) => state.default);
|
const defaultState = projectState.projectStates?.find((state) => state.default);
|
||||||
@ -104,11 +108,11 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
|
|||||||
const isGroupByCreatedBy = group_by === "created_by";
|
const isGroupByCreatedBy = group_by === "created_by";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full">
|
<div ref={containerRef} className="relative overflow-auto h-full w-full">
|
||||||
{list &&
|
{groups &&
|
||||||
list.length > 0 &&
|
groups.length > 0 &&
|
||||||
list.map(
|
groups.map(
|
||||||
(_list: any) =>
|
(_list: IGroupByColumn) =>
|
||||||
validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[_list.id]) && (
|
validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[_list.id]) && (
|
||||||
<div key={_list.id} className={`flex flex-shrink-0 flex-col`}>
|
<div key={_list.id} className={`flex flex-shrink-0 flex-col`}>
|
||||||
<div className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 px-3 py-1">
|
<div className="sticky top-0 z-[2] w-full flex-shrink-0 border-b border-custom-border-200 bg-custom-background-90 px-3 py-1">
|
||||||
@ -131,6 +135,7 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
|
|||||||
quickActions={quickActions}
|
quickActions={quickActions}
|
||||||
displayProperties={displayProperties}
|
displayProperties={displayProperties}
|
||||||
canEditProperties={canEditProperties}
|
canEditProperties={canEditProperties}
|
||||||
|
containerRef={containerRef}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useRef, useState } from "react";
|
import { Dispatch, MutableRefObject, SetStateAction, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// icons
|
// icons
|
||||||
@ -7,6 +7,7 @@ import { ChevronRight, MoreHorizontal } from "lucide-react";
|
|||||||
import { SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet";
|
import { SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet";
|
||||||
// components
|
// components
|
||||||
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
|
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
|
||||||
|
import RenderIfVisible from "components/core/render-if-visible-HOC";
|
||||||
import { IssueColumn } from "./issue-column";
|
import { IssueColumn } from "./issue-column";
|
||||||
// ui
|
// ui
|
||||||
import { ControlLink, Tooltip } from "@plane/ui";
|
import { ControlLink, Tooltip } from "@plane/ui";
|
||||||
@ -32,6 +33,9 @@ interface Props {
|
|||||||
portalElement: React.MutableRefObject<HTMLDivElement | null>;
|
portalElement: React.MutableRefObject<HTMLDivElement | null>;
|
||||||
nestingLevel: number;
|
nestingLevel: number;
|
||||||
issueId: string;
|
issueId: string;
|
||||||
|
isScrolled: MutableRefObject<boolean>;
|
||||||
|
containerRef: MutableRefObject<HTMLTableElement | null>;
|
||||||
|
issueIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SpreadsheetIssueRow = observer((props: Props) => {
|
export const SpreadsheetIssueRow = observer((props: Props) => {
|
||||||
@ -44,8 +48,96 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
|
|||||||
handleIssues,
|
handleIssues,
|
||||||
quickActions,
|
quickActions,
|
||||||
canEditProperties,
|
canEditProperties,
|
||||||
|
isScrolled,
|
||||||
|
containerRef,
|
||||||
|
issueIds,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const [isExpanded, setExpanded] = useState<boolean>(false);
|
||||||
|
const { subIssues: subIssuesStore } = useIssueDetail();
|
||||||
|
|
||||||
|
const subIssues = subIssuesStore.subIssuesByIssueId(issueId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* first column/ issue name and key column */}
|
||||||
|
<RenderIfVisible
|
||||||
|
as="tr"
|
||||||
|
defaultHeight="calc(2.75rem - 1px)"
|
||||||
|
root={containerRef}
|
||||||
|
placeholderChildren={<td colSpan={100} className="border-b-[0.5px]" />}
|
||||||
|
changingReference={issueIds}
|
||||||
|
>
|
||||||
|
<IssueRowDetails
|
||||||
|
issueId={issueId}
|
||||||
|
displayProperties={displayProperties}
|
||||||
|
quickActions={quickActions}
|
||||||
|
canEditProperties={canEditProperties}
|
||||||
|
nestingLevel={nestingLevel}
|
||||||
|
isEstimateEnabled={isEstimateEnabled}
|
||||||
|
handleIssues={handleIssues}
|
||||||
|
portalElement={portalElement}
|
||||||
|
isScrolled={isScrolled}
|
||||||
|
isExpanded={isExpanded}
|
||||||
|
setExpanded={setExpanded}
|
||||||
|
/>
|
||||||
|
</RenderIfVisible>
|
||||||
|
|
||||||
|
{isExpanded &&
|
||||||
|
subIssues &&
|
||||||
|
subIssues.length > 0 &&
|
||||||
|
subIssues.map((subIssueId: string) => (
|
||||||
|
<SpreadsheetIssueRow
|
||||||
|
key={subIssueId}
|
||||||
|
issueId={subIssueId}
|
||||||
|
displayProperties={displayProperties}
|
||||||
|
quickActions={quickActions}
|
||||||
|
canEditProperties={canEditProperties}
|
||||||
|
nestingLevel={nestingLevel + 1}
|
||||||
|
isEstimateEnabled={isEstimateEnabled}
|
||||||
|
handleIssues={handleIssues}
|
||||||
|
portalElement={portalElement}
|
||||||
|
isScrolled={isScrolled}
|
||||||
|
containerRef={containerRef}
|
||||||
|
issueIds={issueIds}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
interface IssueRowDetailsProps {
|
||||||
|
displayProperties: IIssueDisplayProperties;
|
||||||
|
isEstimateEnabled: boolean;
|
||||||
|
quickActions: (
|
||||||
|
issue: TIssue,
|
||||||
|
customActionButton?: React.ReactElement,
|
||||||
|
portalElement?: HTMLDivElement | null
|
||||||
|
) => React.ReactNode;
|
||||||
|
canEditProperties: (projectId: string | undefined) => boolean;
|
||||||
|
handleIssues: (issue: TIssue, action: EIssueActions) => Promise<void>;
|
||||||
|
portalElement: React.MutableRefObject<HTMLDivElement | null>;
|
||||||
|
nestingLevel: number;
|
||||||
|
issueId: string;
|
||||||
|
isScrolled: MutableRefObject<boolean>;
|
||||||
|
isExpanded: boolean;
|
||||||
|
setExpanded: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IssueRowDetails = observer((props: IssueRowDetailsProps) => {
|
||||||
|
const {
|
||||||
|
displayProperties,
|
||||||
|
issueId,
|
||||||
|
isEstimateEnabled,
|
||||||
|
nestingLevel,
|
||||||
|
portalElement,
|
||||||
|
handleIssues,
|
||||||
|
quickActions,
|
||||||
|
canEditProperties,
|
||||||
|
isScrolled,
|
||||||
|
isExpanded,
|
||||||
|
setExpanded,
|
||||||
|
} = props;
|
||||||
// router
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug } = router.query;
|
const { workspaceSlug } = router.query;
|
||||||
@ -54,8 +146,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
|
|||||||
const { peekIssue, setPeekIssue } = useIssueDetail();
|
const { peekIssue, setPeekIssue } = useIssueDetail();
|
||||||
// states
|
// states
|
||||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||||
const [isExpanded, setExpanded] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const menuActionRef = useRef<HTMLDivElement | null>(null);
|
const menuActionRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const handleIssuePeekOverview = (issue: TIssue) => {
|
const handleIssuePeekOverview = (issue: TIssue) => {
|
||||||
@ -66,7 +156,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
|
|||||||
const { subIssues: subIssuesStore, issue } = useIssueDetail();
|
const { subIssues: subIssuesStore, issue } = useIssueDetail();
|
||||||
|
|
||||||
const issueDetail = issue.getIssueById(issueId);
|
const issueDetail = issue.getIssueById(issueId);
|
||||||
const subIssues = subIssuesStore.subIssuesByIssueId(issueId);
|
|
||||||
|
|
||||||
const paddingLeft = `${nestingLevel * 54}px`;
|
const paddingLeft = `${nestingLevel * 54}px`;
|
||||||
|
|
||||||
@ -91,81 +180,77 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
|
|||||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!issueDetail) return null;
|
if (!issueDetail) return null;
|
||||||
|
|
||||||
const disableUserActions = !canEditProperties(issueDetail.project_id);
|
const disableUserActions = !canEditProperties(issueDetail.project_id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr
|
<td
|
||||||
className={cn({
|
className={cn(
|
||||||
"border border-custom-primary-70 hover:border-custom-primary-70": peekIssue?.issueId === issueDetail.id,
|
"sticky group left-0 h-11 w-[28rem] flex items-center bg-custom-background-100 text-sm after:absolute border-r-[0.5px] border-custom-border-200",
|
||||||
})}
|
{
|
||||||
|
"border-b-[0.5px]": peekIssue?.issueId !== issueDetail.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"border border-custom-primary-70 hover:border-custom-primary-70": peekIssue?.issueId === issueDetail.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"shadow-[8px_22px_22px_10px_rgba(0,0,0,0.05)]": isScrolled.current,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
{/* first column/ issue name and key column */}
|
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="key">
|
||||||
<td
|
<div
|
||||||
className={cn(
|
className="flex min-w-min items-center gap-1.5 px-4 py-2.5 pr-0"
|
||||||
"sticky group left-0 h-11 w-[28rem] flex items-center bg-custom-background-100 text-sm after:absolute border-r-[0.5px] border-custom-border-200 focus:border-custom-primary-70",
|
style={issueDetail.parent_id && nestingLevel !== 0 ? { paddingLeft } : {}}
|
||||||
{
|
>
|
||||||
"border-b-[0.5px]": peekIssue?.issueId !== issueDetail.id,
|
<div className="relative flex cursor-pointer items-center text-center text-xs hover:text-custom-text-100">
|
||||||
}
|
<span
|
||||||
)}
|
className={`flex items-center justify-center font-medium group-hover:opacity-0 ${
|
||||||
tabIndex={0}
|
isMenuActive ? "opacity-0" : "opacity-100"
|
||||||
>
|
}`}
|
||||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="key">
|
>
|
||||||
<div
|
{getProjectById(issueDetail.project_id)?.identifier}-{issueDetail.sequence_id}
|
||||||
className="flex min-w-min items-center gap-1.5 px-4 py-2.5 pr-0"
|
</span>
|
||||||
style={issueDetail.parent_id && nestingLevel !== 0 ? { paddingLeft } : {}}
|
|
||||||
>
|
|
||||||
<div className="relative flex cursor-pointer items-center text-center text-xs hover:text-custom-text-100">
|
|
||||||
<span
|
|
||||||
className={`flex items-center justify-center font-medium group-hover:opacity-0 ${
|
|
||||||
isMenuActive ? "opacity-0" : "opacity-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{getProjectById(issueDetail.project_id)?.identifier}-{issueDetail.sequence_id}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{canEditProperties(issueDetail.project_id) && (
|
{canEditProperties(issueDetail.project_id) && (
|
||||||
<div className={`absolute left-2.5 top-0 hidden group-hover:block ${isMenuActive ? "!block" : ""}`}>
|
<div className={`absolute left-2.5 top-0 hidden group-hover:block ${isMenuActive ? "!block" : ""}`}>
|
||||||
{quickActions(issueDetail, customActionButton, portalElement.current)}
|
{quickActions(issueDetail, customActionButton, portalElement.current)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{issueDetail.sub_issues_count > 0 && (
|
|
||||||
<div className="flex h-6 w-6 items-center justify-center">
|
|
||||||
<button
|
|
||||||
className="h-5 w-5 cursor-pointer rounded-sm hover:bg-custom-background-90 hover:text-custom-text-100"
|
|
||||||
onClick={() => handleToggleExpand()}
|
|
||||||
>
|
|
||||||
<ChevronRight className={`h-3.5 w-3.5 ${isExpanded ? "rotate-90" : ""}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</WithDisplayPropertiesHOC>
|
|
||||||
<ControlLink
|
{issueDetail.sub_issues_count > 0 && (
|
||||||
href={`/${workspaceSlug}/projects/${issueDetail.project_id}/issues/${issueId}`}
|
<div className="flex h-6 w-6 items-center justify-center">
|
||||||
target="_blank"
|
<button
|
||||||
onClick={() => handleIssuePeekOverview(issueDetail)}
|
className="h-5 w-5 cursor-pointer rounded-sm hover:bg-custom-background-90 hover:text-custom-text-100"
|
||||||
className="clickable w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
|
onClick={() => handleToggleExpand()}
|
||||||
>
|
|
||||||
<div className="w-full overflow-hidden">
|
|
||||||
<Tooltip tooltipHeading="Title" tooltipContent={issueDetail.name}>
|
|
||||||
<div
|
|
||||||
className="h-full w-full cursor-pointer truncate px-4 py-2.5 text-left text-[0.825rem] text-custom-text-100"
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
>
|
||||||
{issueDetail.name}
|
<ChevronRight className={`h-3.5 w-3.5 ${isExpanded ? "rotate-90" : ""}`} />
|
||||||
</div>
|
</button>
|
||||||
</Tooltip>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</ControlLink>
|
</div>
|
||||||
</td>
|
</WithDisplayPropertiesHOC>
|
||||||
{/* Rest of the columns */}
|
<ControlLink
|
||||||
{SPREADSHEET_PROPERTY_LIST.map((property) => (
|
href={`/${workspaceSlug}/projects/${issueDetail.project_id}/issues/${issueId}`}
|
||||||
|
target="_blank"
|
||||||
|
onClick={() => handleIssuePeekOverview(issueDetail)}
|
||||||
|
className="clickable w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
|
||||||
|
>
|
||||||
|
<div className="w-full overflow-hidden">
|
||||||
|
<Tooltip tooltipHeading="Title" tooltipContent={issueDetail.name}>
|
||||||
|
<div className="h-full w-full cursor-pointer truncate px-4 py-2.5 text-left text-[0.825rem] text-custom-text-100" tabIndex={-1}>
|
||||||
|
{issueDetail.name}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</ControlLink>
|
||||||
|
</td>
|
||||||
|
{/* Rest of the columns */}
|
||||||
|
{SPREADSHEET_PROPERTY_LIST.map((property) => (
|
||||||
<IssueColumn
|
<IssueColumn
|
||||||
displayProperties={displayProperties}
|
displayProperties={displayProperties}
|
||||||
issueDetail={issueDetail}
|
issueDetail={issueDetail}
|
||||||
@ -175,24 +260,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
|
|||||||
isEstimateEnabled={isEstimateEnabled}
|
isEstimateEnabled={isEstimateEnabled}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tr>
|
|
||||||
|
|
||||||
{isExpanded &&
|
|
||||||
subIssues &&
|
|
||||||
subIssues.length > 0 &&
|
|
||||||
subIssues.map((subIssueId: string) => (
|
|
||||||
<SpreadsheetIssueRow
|
|
||||||
key={subIssueId}
|
|
||||||
issueId={subIssueId}
|
|
||||||
displayProperties={displayProperties}
|
|
||||||
quickActions={quickActions}
|
|
||||||
canEditProperties={canEditProperties}
|
|
||||||
nestingLevel={nestingLevel + 1}
|
|
||||||
isEstimateEnabled={isEstimateEnabled}
|
|
||||||
handleIssues={handleIssues}
|
|
||||||
portalElement={portalElement}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
|
import { MutableRefObject, useEffect, useRef } from "react";
|
||||||
//types
|
//types
|
||||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssue } from "@plane/types";
|
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssue } from "@plane/types";
|
||||||
import { EIssueActions } from "../types";
|
import { EIssueActions } from "../types";
|
||||||
@ -21,6 +22,7 @@ type Props = {
|
|||||||
handleIssues: (issue: TIssue, action: EIssueActions) => Promise<void>;
|
handleIssues: (issue: TIssue, action: EIssueActions) => Promise<void>;
|
||||||
canEditProperties: (projectId: string | undefined) => boolean;
|
canEditProperties: (projectId: string | undefined) => boolean;
|
||||||
portalElement: React.MutableRefObject<HTMLDivElement | null>;
|
portalElement: React.MutableRefObject<HTMLDivElement | null>;
|
||||||
|
containerRef: MutableRefObject<HTMLTableElement | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SpreadsheetTable = observer((props: Props) => {
|
export const SpreadsheetTable = observer((props: Props) => {
|
||||||
@ -34,8 +36,45 @@ export const SpreadsheetTable = observer((props: Props) => {
|
|||||||
quickActions,
|
quickActions,
|
||||||
handleIssues,
|
handleIssues,
|
||||||
canEditProperties,
|
canEditProperties,
|
||||||
|
containerRef,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
// states
|
||||||
|
const isScrolled = useRef(false);
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!containerRef.current) return;
|
||||||
|
const scrollLeft = containerRef.current.scrollLeft;
|
||||||
|
|
||||||
|
const columnShadow = "8px 22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for regular columns
|
||||||
|
const headerShadow = "8px -22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for headers
|
||||||
|
|
||||||
|
//The shadow styles are added this way to avoid re-render of all the rows of table, which could be costly
|
||||||
|
if (scrollLeft > 0 !== isScrolled.current) {
|
||||||
|
const firtColumns = containerRef.current.querySelectorAll("table tr td:first-child, th:first-child");
|
||||||
|
|
||||||
|
for (let i = 0; i < firtColumns.length; i++) {
|
||||||
|
const shadow = i === 0 ? headerShadow : columnShadow;
|
||||||
|
if (scrollLeft > 0) {
|
||||||
|
(firtColumns[i] as HTMLElement).style.boxShadow = shadow;
|
||||||
|
} else {
|
||||||
|
(firtColumns[i] as HTMLElement).style.boxShadow = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isScrolled.current = scrollLeft > 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentContainerRef = containerRef.current;
|
||||||
|
|
||||||
|
if (currentContainerRef) currentContainerRef.addEventListener("scroll", handleScroll);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (currentContainerRef) currentContainerRef.removeEventListener("scroll", handleScroll);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleKeyBoardNavigation = useTableKeyboardNavigation();
|
const handleKeyBoardNavigation = useTableKeyboardNavigation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -58,6 +97,9 @@ export const SpreadsheetTable = observer((props: Props) => {
|
|||||||
isEstimateEnabled={isEstimateEnabled}
|
isEstimateEnabled={isEstimateEnabled}
|
||||||
handleIssues={handleIssues}
|
handleIssues={handleIssues}
|
||||||
portalElement={portalElement}
|
portalElement={portalElement}
|
||||||
|
containerRef={containerRef}
|
||||||
|
isScrolled={isScrolled}
|
||||||
|
issueIds={issueIds}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useRef } from "react";
|
import React, { useRef } from "react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// components
|
// components
|
||||||
import { Spinner } from "@plane/ui";
|
import { Spinner } from "@plane/ui";
|
||||||
@ -48,8 +48,6 @@ export const SpreadsheetView: React.FC<Props> = observer((props) => {
|
|||||||
enableQuickCreateIssue,
|
enableQuickCreateIssue,
|
||||||
disableIssueCreation,
|
disableIssueCreation,
|
||||||
} = props;
|
} = props;
|
||||||
// states
|
|
||||||
const isScrolled = useRef(false);
|
|
||||||
// refs
|
// refs
|
||||||
const containerRef = useRef<HTMLTableElement | null>(null);
|
const containerRef = useRef<HTMLTableElement | null>(null);
|
||||||
const portalRef = useRef<HTMLDivElement | null>(null);
|
const portalRef = useRef<HTMLDivElement | null>(null);
|
||||||
@ -58,39 +56,6 @@ export const SpreadsheetView: React.FC<Props> = observer((props) => {
|
|||||||
|
|
||||||
const isEstimateEnabled: boolean = currentProjectDetails?.estimate !== null;
|
const isEstimateEnabled: boolean = currentProjectDetails?.estimate !== null;
|
||||||
|
|
||||||
const handleScroll = () => {
|
|
||||||
if (!containerRef.current) return;
|
|
||||||
const scrollLeft = containerRef.current.scrollLeft;
|
|
||||||
|
|
||||||
const columnShadow = "8px 22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for regular columns
|
|
||||||
const headerShadow = "8px -22px 22px 10px rgba(0, 0, 0, 0.05)"; // shadow for headers
|
|
||||||
|
|
||||||
//The shadow styles are added this way to avoid re-render of all the rows of table, which could be costly
|
|
||||||
if (scrollLeft > 0 !== isScrolled.current) {
|
|
||||||
const firtColumns = containerRef.current.querySelectorAll("table tr td:first-child, th:first-child");
|
|
||||||
|
|
||||||
for (let i = 0; i < firtColumns.length; i++) {
|
|
||||||
const shadow = i === 0 ? headerShadow : columnShadow;
|
|
||||||
if (scrollLeft > 0) {
|
|
||||||
(firtColumns[i] as HTMLElement).style.boxShadow = shadow;
|
|
||||||
} else {
|
|
||||||
(firtColumns[i] as HTMLElement).style.boxShadow = "none";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isScrolled.current = scrollLeft > 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const currentContainerRef = containerRef.current;
|
|
||||||
|
|
||||||
if (currentContainerRef) currentContainerRef.addEventListener("scroll", handleScroll);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (currentContainerRef) currentContainerRef.removeEventListener("scroll", handleScroll);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!issueIds || issueIds.length === 0)
|
if (!issueIds || issueIds.length === 0)
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full w-full place-items-center">
|
<div className="grid h-full w-full place-items-center">
|
||||||
@ -112,6 +77,7 @@ export const SpreadsheetView: React.FC<Props> = observer((props) => {
|
|||||||
quickActions={quickActions}
|
quickActions={quickActions}
|
||||||
handleIssues={handleIssues}
|
handleIssues={handleIssues}
|
||||||
canEditProperties={canEditProperties}
|
canEditProperties={canEditProperties}
|
||||||
|
containerRef={containerRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-custom-border-100">
|
<div className="border-t border-custom-border-100">
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { Avatar, PriorityIcon, StateGroupIcon } from "@plane/ui";
|
import { Avatar, PriorityIcon, StateGroupIcon } from "@plane/ui";
|
||||||
import { ISSUE_PRIORITIES } from "constants/issue";
|
import { EIssueListRow, ISSUE_PRIORITIES } from "constants/issue";
|
||||||
import { renderEmoji } from "helpers/emoji.helper";
|
import { renderEmoji } from "helpers/emoji.helper";
|
||||||
import { IMemberRootStore } from "store/member";
|
import { IMemberRootStore } from "store/member";
|
||||||
import { IProjectStore } from "store/project/project.store";
|
import { IProjectStore } from "store/project/project.store";
|
||||||
import { IStateStore } from "store/state.store";
|
import { IStateStore } from "store/state.store";
|
||||||
import { GroupByColumnTypes, IGroupByColumn } from "@plane/types";
|
import { GroupByColumnTypes, IGroupByColumn, IIssueListRow, TGroupedIssues, TUnGroupedIssues } from "@plane/types";
|
||||||
import { STATE_GROUPS } from "constants/state";
|
import { STATE_GROUPS } from "constants/state";
|
||||||
import { ILabelStore } from "store/label.store";
|
import { ILabelStore } from "store/label.store";
|
||||||
|
|
||||||
|
@ -412,6 +412,13 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export enum EIssueListRow {
|
||||||
|
HEADER = "HEADER",
|
||||||
|
ISSUE = "ISSUE",
|
||||||
|
NO_ISSUES = "NO_ISSUES",
|
||||||
|
QUICK_ADD = "QUICK_ADD",
|
||||||
|
}
|
||||||
|
|
||||||
export const getValueFromObject = (object: Object, key: string): string | number | boolean | null => {
|
export const getValueFromObject = (object: Object, key: string): string | number | boolean | null => {
|
||||||
const keys = key ? key.split(".") : [];
|
const keys = key ? key.split(".") : [];
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user