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:
rahulramesha 2024-02-09 15:53:15 +05:30 committed by GitHub
parent eb4c3a4db5
commit e2affc3fa6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 467 additions and 237 deletions

View File

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

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

View File

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

View File

@ -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,16 +136,22 @@ 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 }
)} )}
>
<RenderIfVisible
classNames="space-y-2"
root={scrollableContainerRef}
defaultHeight="100px"
horizonatlOffset={50}
alwaysRender={snapshot.isDragging}
pauseHeightUpdateWhileRendering={isDragStarted}
changingReference={issueIds}
> >
<KanbanIssueDetailsBlock <KanbanIssueDetailsBlock
issue={issue} issue={issue}
@ -147,6 +160,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
quickActions={quickActions} quickActions={quickActions}
isReadOnly={!canEditIssueProperties} isReadOnly={!canEditIssueProperties}
/> />
</RenderIfVisible>
</div> </div>
</div> </div>
)} )}

View File

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

View File

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

View File

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

View File

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

View File

@ -122,7 +122,6 @@ 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}
@ -142,6 +141,5 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
isCompletedCycle={isCompletedCycle} isCompletedCycle={isCompletedCycle}
/> />
</div> </div>
</>
); );
}); });

View File

@ -48,16 +48,12 @@ 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( className={cn("min-h-12 relative flex items-center gap-3 bg-custom-background-100 p-3 text-sm", {
"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": "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">
@ -106,6 +102,5 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
)} )}
</div> </div>
</div> </div>
</>
); );
}); });

View File

@ -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,20 +13,26 @@ 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 (
<RenderIfVisible
key={`${issueId}`}
defaultHeight="3rem"
root={containerRef}
classNames={"relative border border-transparent border-b-custom-border-200 last:border-b-transparent"}
changingReference={issueIds}
>
<IssueBlock <IssueBlock
key={issueId}
issueId={issueId} issueId={issueId}
issuesMap={issuesMap} issuesMap={issuesMap}
handleIssues={handleIssues} handleIssues={handleIssues}
@ -33,6 +40,7 @@ export const IssueBlocksList: FC<Props> = (props) => {
canEditProperties={canEditProperties} canEditProperties={canEditProperties}
displayProperties={displayProperties} displayProperties={displayProperties}
/> />
</RenderIfVisible>
); );
}) })
) : ( ) : (

View File

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

View File

@ -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,24 +180,23 @@ 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
className={cn({
"border border-custom-primary-70 hover:border-custom-primary-70": peekIssue?.issueId === issueDetail.id,
})}
>
{/* first column/ issue name and key column */}
<td <td
className={cn( className={cn(
"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", "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-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} tabIndex={0}
@ -154,10 +242,7 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
> >
<div className="w-full overflow-hidden"> <div className="w-full overflow-hidden">
<Tooltip tooltipHeading="Title" tooltipContent={issueDetail.name}> <Tooltip tooltipHeading="Title" tooltipContent={issueDetail.name}>
<div <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}>
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} {issueDetail.name}
</div> </div>
</Tooltip> </Tooltip>
@ -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}
/>
))}
</> </>
); );
}); });

View File

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

View File

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

View File

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

View File

@ -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(".") : [];