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 {
|
||||
[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 { useRouter } from "next/router";
|
||||
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 scrollableContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// states
|
||||
const [isDragStarted, setIsDragStarted] = useState<boolean>(false);
|
||||
const [dragState, setDragState] = useState<KanbanDragState>({});
|
||||
@ -245,7 +247,10 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
</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">
|
||||
<DragDropContext onDragStart={onDragStart} onDragEnd={onDragEnd}>
|
||||
{/* drag and delete component */}
|
||||
@ -289,6 +294,8 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
canEditProperties={canEditProperties}
|
||||
storeType={storeType}
|
||||
addIssuesToView={addIssuesToView}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
isDragStarted={isDragStarted}
|
||||
/>
|
||||
</DragDropContext>
|
||||
</div>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { memo } from "react";
|
||||
import { MutableRefObject, memo } from "react";
|
||||
import { Draggable, DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
@ -13,6 +13,7 @@ import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types";
|
||||
import { EIssueActions } from "../types";
|
||||
// helper
|
||||
import { cn } from "helpers/common.helper";
|
||||
import RenderIfVisible from "components/core/render-if-visible-HOC";
|
||||
|
||||
interface IssueBlockProps {
|
||||
peekIssueId?: string;
|
||||
@ -25,6 +26,9 @@ interface IssueBlockProps {
|
||||
handleIssues: (issue: TIssue, action: EIssueActions) => void;
|
||||
quickActions: (issue: TIssue) => React.ReactNode;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
isDragStarted?: boolean;
|
||||
issueIds: string[]; //DO NOT REMOVE< needed to force render for virtualization
|
||||
}
|
||||
|
||||
interface IssueDetailsBlockProps {
|
||||
@ -107,6 +111,9 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
|
||||
handleIssues,
|
||||
quickActions,
|
||||
canEditProperties,
|
||||
scrollableContainerRef,
|
||||
isDragStarted,
|
||||
issueIds,
|
||||
} = props;
|
||||
|
||||
const issue = issuesMap[issueId];
|
||||
@ -129,16 +136,22 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
|
||||
{...provided.dragHandleProps}
|
||||
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
|
||||
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 },
|
||||
{ "border-custom-primary-100": snapshot.isDragging },
|
||||
{ "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
|
||||
issue={issue}
|
||||
@ -147,6 +160,7 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = memo((props) => {
|
||||
quickActions={quickActions}
|
||||
isReadOnly={!canEditIssueProperties}
|
||||
/>
|
||||
</RenderIfVisible>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { memo } from "react";
|
||||
import { MutableRefObject, memo } from "react";
|
||||
//types
|
||||
import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types";
|
||||
import { EIssueActions } from "../types";
|
||||
@ -16,6 +16,8 @@ interface IssueBlocksListProps {
|
||||
handleIssues: (issue: TIssue, action: EIssueActions) => void;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
isDragStarted?: boolean;
|
||||
}
|
||||
|
||||
const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (props) => {
|
||||
@ -30,6 +32,8 @@ const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (props) => {
|
||||
handleIssues,
|
||||
quickActions,
|
||||
canEditProperties,
|
||||
scrollableContainerRef,
|
||||
isDragStarted,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
@ -56,6 +60,9 @@ const KanbanIssueBlocksListMemo: React.FC<IssueBlocksListProps> = (props) => {
|
||||
index={index}
|
||||
isDragDisabled={isDragDisabled}
|
||||
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 { getGroupByColumns } from "../utils";
|
||||
import { TCreateModalStoreTypes } from "constants/issue";
|
||||
import { MutableRefObject } from "react";
|
||||
|
||||
export interface IGroupByKanBan {
|
||||
issuesMap: IIssueMap;
|
||||
@ -45,6 +46,8 @@ export interface IGroupByKanBan {
|
||||
storeType?: TCreateModalStoreTypes;
|
||||
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
isDragStarted?: boolean;
|
||||
}
|
||||
|
||||
const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||
@ -67,6 +70,8 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||
storeType,
|
||||
addIssuesToView,
|
||||
canEditProperties,
|
||||
scrollableContainerRef,
|
||||
isDragStarted,
|
||||
} = props;
|
||||
|
||||
const member = useMember();
|
||||
@ -92,11 +97,7 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||
const groupByVisibilityToggle = visibilityGroupBy(_list);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-shrink-0 flex-col h-full group ${
|
||||
groupByVisibilityToggle ? `` : `w-[340px]`
|
||||
}`}
|
||||
>
|
||||
<div className={`relative flex flex-shrink-0 flex-col group ${groupByVisibilityToggle ? `` : `w-[340px]`}`}>
|
||||
{sub_group_by === null && (
|
||||
<div className="flex-shrink-0 sticky top-0 z-[2] w-full bg-custom-background-90 py-1">
|
||||
<HeaderGroupByCard
|
||||
@ -135,6 +136,8 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||
disableIssueCreation={disableIssueCreation}
|
||||
canEditProperties={canEditProperties}
|
||||
groupByVisibilityToggle={groupByVisibilityToggle}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
isDragStarted={isDragStarted}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -168,6 +171,8 @@ export interface IKanBan {
|
||||
storeType?: TCreateModalStoreTypes;
|
||||
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
isDragStarted?: boolean;
|
||||
}
|
||||
|
||||
export const KanBan: React.FC<IKanBan> = observer((props) => {
|
||||
@ -189,6 +194,8 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
|
||||
storeType,
|
||||
addIssuesToView,
|
||||
canEditProperties,
|
||||
scrollableContainerRef,
|
||||
isDragStarted,
|
||||
} = props;
|
||||
|
||||
const issueKanBanView = useKanbanView();
|
||||
@ -213,6 +220,8 @@ export const KanBan: React.FC<IKanBan> = observer((props) => {
|
||||
storeType={storeType}
|
||||
addIssuesToView={addIssuesToView}
|
||||
canEditProperties={canEditProperties}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
isDragStarted={isDragStarted}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { MutableRefObject } from "react";
|
||||
import { Droppable } from "@hello-pangea/dnd";
|
||||
// hooks
|
||||
import { useProjectState } from "hooks/store";
|
||||
@ -37,6 +38,8 @@ interface IKanbanGroup {
|
||||
disableIssueCreation?: boolean;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
groupByVisibilityToggle: boolean;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
isDragStarted?: boolean;
|
||||
}
|
||||
|
||||
export const KanbanGroup = (props: IKanbanGroup) => {
|
||||
@ -57,6 +60,8 @@ export const KanbanGroup = (props: IKanbanGroup) => {
|
||||
disableIssueCreation,
|
||||
quickAddCallback,
|
||||
viewId,
|
||||
scrollableContainerRef,
|
||||
isDragStarted,
|
||||
} = props;
|
||||
// hooks
|
||||
const projectState = useProjectState();
|
||||
@ -127,6 +132,8 @@ export const KanbanGroup = (props: IKanbanGroup) => {
|
||||
handleIssues={handleIssues}
|
||||
quickActions={quickActions}
|
||||
canEditProperties={canEditProperties}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
isDragStarted={isDragStarted}
|
||||
/>
|
||||
|
||||
{provided.placeholder}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { MutableRefObject } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { KanBan } from "./default";
|
||||
@ -80,6 +81,7 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader {
|
||||
viewId?: string
|
||||
) => Promise<TIssue | undefined>;
|
||||
viewId?: string;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
||||
const {
|
||||
@ -99,6 +101,8 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
||||
addIssuesToView,
|
||||
quickAddCallback,
|
||||
viewId,
|
||||
scrollableContainerRef,
|
||||
isDragStarted,
|
||||
} = props;
|
||||
|
||||
const calculateIssueCount = (column_id: string) => {
|
||||
@ -150,6 +154,8 @@ const SubGroupSwimlane: React.FC<ISubGroupSwimlane> = observer((props) => {
|
||||
addIssuesToView={addIssuesToView}
|
||||
quickAddCallback={quickAddCallback}
|
||||
viewId={viewId}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
isDragStarted={isDragStarted}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -183,6 +189,7 @@ export interface IKanBanSwimLanes {
|
||||
) => Promise<TIssue | undefined>;
|
||||
viewId?: string;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
||||
@ -204,6 +211,7 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
||||
addIssuesToView,
|
||||
quickAddCallback,
|
||||
viewId,
|
||||
scrollableContainerRef,
|
||||
} = props;
|
||||
|
||||
const member = useMember();
|
||||
@ -249,6 +257,7 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
||||
canEditProperties={canEditProperties}
|
||||
quickAddCallback={quickAddCallback}
|
||||
viewId={viewId}
|
||||
scrollableContainerRef={scrollableContainerRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
@ -122,7 +122,6 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`relative h-full w-full bg-custom-background-90`}>
|
||||
<List
|
||||
issuesMap={issueMap}
|
||||
@ -142,6 +141,5 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
|
||||
isCompletedCycle={isCompletedCycle}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -48,16 +48,12 @@ export const IssueBlock: React.FC<IssueBlockProps> = observer((props: IssueBlock
|
||||
const projectDetails = getProjectById(issue.project_id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex items-center gap-3 bg-custom-background-100 p-3 text-sm border border-transparent border-b-custom-border-200",
|
||||
{
|
||||
className={cn("min-h-12 relative flex items-center gap-3 bg-custom-background-100 p-3 text-sm", {
|
||||
"border border-custom-primary-70 hover:border-custom-primary-70":
|
||||
peekIssue && peekIssue.issueId === issue.id,
|
||||
"last:border-b-transparent": peekIssue?.issueId !== issue.id,
|
||||
}
|
||||
)}
|
||||
"last:border-b-transparent": peekIssue?.issueId !== issue.id
|
||||
})}
|
||||
>
|
||||
{displayProperties && displayProperties?.key && (
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { FC } from "react";
|
||||
import { FC, MutableRefObject } from "react";
|
||||
// components
|
||||
import { IssueBlock } from "components/issues";
|
||||
// types
|
||||
import { TGroupedIssues, TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types";
|
||||
import { EIssueActions } from "../types";
|
||||
import RenderIfVisible from "components/core/render-if-visible-HOC";
|
||||
|
||||
interface Props {
|
||||
issueIds: TGroupedIssues | TUnGroupedIssues | any;
|
||||
@ -12,20 +13,26 @@ interface Props {
|
||||
handleIssues: (issue: TIssue, action: EIssueActions) => Promise<void>;
|
||||
quickActions: (issue: TIssue) => React.ReactNode;
|
||||
displayProperties: IIssueDisplayProperties | undefined;
|
||||
containerRef: MutableRefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
export const IssueBlocksList: FC<Props> = (props) => {
|
||||
const { issueIds, issuesMap, handleIssues, quickActions, displayProperties, canEditProperties } = props;
|
||||
const { issueIds, issuesMap, handleIssues, quickActions, displayProperties, canEditProperties, containerRef } = props;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{issueIds && issueIds.length > 0 ? (
|
||||
issueIds.map((issueId: string) => {
|
||||
if (!issueId) return null;
|
||||
|
||||
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
|
||||
key={issueId}
|
||||
issueId={issueId}
|
||||
issuesMap={issuesMap}
|
||||
handleIssues={handleIssues}
|
||||
@ -33,6 +40,7 @@ export const IssueBlocksList: FC<Props> = (props) => {
|
||||
canEditProperties={canEditProperties}
|
||||
displayProperties={displayProperties}
|
||||
/>
|
||||
</RenderIfVisible>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { useRef } from "react";
|
||||
// components
|
||||
import { IssueBlocksList, ListQuickAddIssueForm } from "components/issues";
|
||||
import { HeaderGroupByCard } from "./headers/group-by-card";
|
||||
// hooks
|
||||
import { useLabel, useMember, useProject, useProjectState } from "hooks/store";
|
||||
// types
|
||||
@ -10,12 +12,12 @@ import {
|
||||
IIssueDisplayProperties,
|
||||
TIssueMap,
|
||||
TUnGroupedIssues,
|
||||
IGroupByColumn,
|
||||
} from "@plane/types";
|
||||
import { EIssueActions } from "../types";
|
||||
// constants
|
||||
import { HeaderGroupByCard } from "./headers/group-by-card";
|
||||
import { getGroupByColumns } from "../utils";
|
||||
import { TCreateModalStoreTypes } from "constants/issue";
|
||||
import { getGroupByColumns } from "../utils";
|
||||
|
||||
export interface IGroupByList {
|
||||
issueIds: TGroupedIssues | TUnGroupedIssues | any;
|
||||
@ -64,9 +66,11 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
|
||||
const label = useLabel();
|
||||
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 defaultState = projectState.projectStates?.find((state) => state.default);
|
||||
@ -104,11 +108,11 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
|
||||
const isGroupByCreatedBy = group_by === "created_by";
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{list &&
|
||||
list.length > 0 &&
|
||||
list.map(
|
||||
(_list: any) =>
|
||||
<div ref={containerRef} className="relative overflow-auto h-full w-full">
|
||||
{groups &&
|
||||
groups.length > 0 &&
|
||||
groups.map(
|
||||
(_list: IGroupByColumn) =>
|
||||
validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[_list.id]) && (
|
||||
<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">
|
||||
@ -131,6 +135,7 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
|
||||
quickActions={quickActions}
|
||||
displayProperties={displayProperties}
|
||||
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 { observer } from "mobx-react-lite";
|
||||
// icons
|
||||
@ -7,6 +7,7 @@ import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
import { SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet";
|
||||
// components
|
||||
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
|
||||
import RenderIfVisible from "components/core/render-if-visible-HOC";
|
||||
import { IssueColumn } from "./issue-column";
|
||||
// ui
|
||||
import { ControlLink, Tooltip } from "@plane/ui";
|
||||
@ -32,6 +33,9 @@ interface Props {
|
||||
portalElement: React.MutableRefObject<HTMLDivElement | null>;
|
||||
nestingLevel: number;
|
||||
issueId: string;
|
||||
isScrolled: MutableRefObject<boolean>;
|
||||
containerRef: MutableRefObject<HTMLTableElement | null>;
|
||||
issueIds: string[];
|
||||
}
|
||||
|
||||
export const SpreadsheetIssueRow = observer((props: Props) => {
|
||||
@ -44,8 +48,96 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
|
||||
handleIssues,
|
||||
quickActions,
|
||||
canEditProperties,
|
||||
isScrolled,
|
||||
containerRef,
|
||||
issueIds,
|
||||
} = 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
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
@ -54,8 +146,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
|
||||
const { peekIssue, setPeekIssue } = useIssueDetail();
|
||||
// states
|
||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
const [isExpanded, setExpanded] = useState<boolean>(false);
|
||||
|
||||
const menuActionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleIssuePeekOverview = (issue: TIssue) => {
|
||||
@ -66,7 +156,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
|
||||
const { subIssues: subIssuesStore, issue } = useIssueDetail();
|
||||
|
||||
const issueDetail = issue.getIssueById(issueId);
|
||||
const subIssues = subIssuesStore.subIssuesByIssueId(issueId);
|
||||
|
||||
const paddingLeft = `${nestingLevel * 54}px`;
|
||||
|
||||
@ -91,24 +180,23 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!issueDetail) return null;
|
||||
|
||||
const disableUserActions = !canEditProperties(issueDetail.project_id);
|
||||
|
||||
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
|
||||
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 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}
|
||||
@ -154,10 +242,7 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
|
||||
>
|
||||
<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}
|
||||
>
|
||||
<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>
|
||||
@ -175,24 +260,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
|
||||
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 { MutableRefObject, useEffect, useRef } from "react";
|
||||
//types
|
||||
import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssue } from "@plane/types";
|
||||
import { EIssueActions } from "../types";
|
||||
@ -21,6 +22,7 @@ type Props = {
|
||||
handleIssues: (issue: TIssue, action: EIssueActions) => Promise<void>;
|
||||
canEditProperties: (projectId: string | undefined) => boolean;
|
||||
portalElement: React.MutableRefObject<HTMLDivElement | null>;
|
||||
containerRef: MutableRefObject<HTMLTableElement | null>;
|
||||
};
|
||||
|
||||
export const SpreadsheetTable = observer((props: Props) => {
|
||||
@ -34,8 +36,45 @@ export const SpreadsheetTable = observer((props: Props) => {
|
||||
quickActions,
|
||||
handleIssues,
|
||||
canEditProperties,
|
||||
containerRef,
|
||||
} = 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();
|
||||
|
||||
return (
|
||||
@ -58,6 +97,9 @@ export const SpreadsheetTable = observer((props: Props) => {
|
||||
isEstimateEnabled={isEstimateEnabled}
|
||||
handleIssues={handleIssues}
|
||||
portalElement={portalElement}
|
||||
containerRef={containerRef}
|
||||
isScrolled={isScrolled}
|
||||
issueIds={issueIds}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import React, { useRef } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { Spinner } from "@plane/ui";
|
||||
@ -48,8 +48,6 @@ export const SpreadsheetView: React.FC<Props> = observer((props) => {
|
||||
enableQuickCreateIssue,
|
||||
disableIssueCreation,
|
||||
} = props;
|
||||
// states
|
||||
const isScrolled = useRef(false);
|
||||
// refs
|
||||
const containerRef = useRef<HTMLTableElement | 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 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)
|
||||
return (
|
||||
<div className="grid h-full w-full place-items-center">
|
||||
@ -112,6 +77,7 @@ export const SpreadsheetView: React.FC<Props> = observer((props) => {
|
||||
quickActions={quickActions}
|
||||
handleIssues={handleIssues}
|
||||
canEditProperties={canEditProperties}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-custom-border-100">
|
||||
|
@ -1,10 +1,10 @@
|
||||
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 { IMemberRootStore } from "store/member";
|
||||
import { IProjectStore } from "store/project/project.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 { 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 => {
|
||||
const keys = key ? key.split(".") : [];
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user