feat: Keyboard navigation spreadsheet layout for issues (#3564)

* enable keyboard navigation for spreadsheet layout

* move the logic to table level instead of cell level

* fix perf issue that made it unusable

* fix scroll issue with navigation

* fix build errors
This commit is contained in:
rahulramesha 2024-02-08 11:49:00 +05:30 committed by GitHub
parent a43dfc097d
commit fb3dd77b66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 368 additions and 126 deletions

View file

@ -7,12 +7,13 @@ import { TIssue } from "@plane/types";
type Props = {
issue: TIssue;
onClose: () => void;
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
disabled: boolean;
};
export const SpreadsheetAssigneeColumn: React.FC<Props> = observer((props: Props) => {
const { issue, onChange, disabled } = props;
const { issue, onChange, disabled, onClose } = props;
return (
<div className="h-11 border-b-[0.5px] border-custom-border-200">
@ -37,6 +38,7 @@ export const SpreadsheetAssigneeColumn: React.FC<Props> = observer((props: Props
}
buttonClassName="text-left"
buttonContainerClassName="w-full"
onClose={onClose}
/>
</div>
);

View file

@ -9,12 +9,13 @@ import { TIssue } from "@plane/types";
type Props = {
issue: TIssue;
onClose: () => void;
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
disabled: boolean;
};
export const SpreadsheetDueDateColumn: React.FC<Props> = observer((props: Props) => {
const { issue, onChange, disabled } = props;
const { issue, onChange, disabled, onClose } = props;
return (
<div className="h-11 border-b-[0.5px] border-custom-border-200">
@ -36,6 +37,7 @@ export const SpreadsheetDueDateColumn: React.FC<Props> = observer((props: Props)
buttonVariant="transparent-with-text"
buttonClassName="rounded-none text-left"
buttonContainerClassName="w-full"
onClose={onClose}
/>
</div>
);

View file

@ -6,12 +6,13 @@ import { TIssue } from "@plane/types";
type Props = {
issue: TIssue;
onClose: () => void;
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
disabled: boolean;
};
export const SpreadsheetEstimateColumn: React.FC<Props> = observer((props: Props) => {
const { issue, onChange, disabled } = props;
const { issue, onChange, disabled, onClose } = props;
return (
<div className="h-11 border-b-[0.5px] border-custom-border-200">
@ -25,6 +26,7 @@ export const SpreadsheetEstimateColumn: React.FC<Props> = observer((props: Props
buttonVariant="transparent-with-text"
buttonClassName="rounded-none text-left"
buttonContainerClassName="w-full"
onClose={onClose}
/>
</div>
);

View file

@ -20,10 +20,11 @@ interface Props {
property: keyof IIssueDisplayProperties;
displayFilters: IIssueDisplayFilterOptions;
handleDisplayFilterUpdate: (data: Partial<IIssueDisplayFilterOptions>) => void;
onClose: () => void;
}
export const SpreadsheetHeaderColumn = (props: Props) => {
const { displayFilters, handleDisplayFilterUpdate, property } = props;
export const HeaderColumn = (props: Props) => {
const { displayFilters, handleDisplayFilterUpdate, property, onClose } = props;
const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage(
"spreadsheetViewSorting",
@ -44,7 +45,8 @@ export const SpreadsheetHeaderColumn = (props: Props) => {
return (
<CustomMenu
customButtonClassName="!w-full"
customButtonClassName="clickable !w-full"
customButtonTabIndex={-1}
className="!w-full"
customButton={
<div className="flex w-full cursor-pointer items-center justify-between gap-1.5 py-2 text-sm text-custom-text-200 hover:text-custom-text-100">
@ -62,6 +64,7 @@ export const SpreadsheetHeaderColumn = (props: Props) => {
</div>
</div>
}
onMenuClose={onClose}
placement="bottom-end"
>
<CustomMenu.MenuItem onClick={() => handleOrderBy(propertyDetails.ascendingOrderKey, property)}>

View file

@ -9,12 +9,13 @@ import { TIssue } from "@plane/types";
type Props = {
issue: TIssue;
onClose: () => void;
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
disabled: boolean;
};
export const SpreadsheetLabelColumn: React.FC<Props> = observer((props: Props) => {
const { issue, onChange, disabled } = props;
const { issue, onChange, disabled, onClose } = props;
// hooks
const { labelMap } = useLabel();
@ -25,13 +26,14 @@ export const SpreadsheetLabelColumn: React.FC<Props> = observer((props: Props) =
projectId={issue.project_id ?? null}
value={issue.label_ids}
defaultOptions={defaultLabelOptions}
onChange={(data) => onChange(issue, { label_ids: data },{ changed_property: "labels", change_details: data })}
onChange={(data) => onChange(issue, { label_ids: data }, { changed_property: "labels", change_details: data })}
className="h-11 w-full border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80"
buttonClassName="px-2.5 h-full"
hideDropdownArrow
maxRender={1}
disabled={disabled}
placeholderText="Select labels"
onClose={onClose}
/>
);
});

View file

@ -7,22 +7,24 @@ import { TIssue } from "@plane/types";
type Props = {
issue: TIssue;
onClose: () => void;
onChange: (issue: TIssue, data: Partial<TIssue>,updates:any) => void;
disabled: boolean;
};
export const SpreadsheetPriorityColumn: React.FC<Props> = observer((props: Props) => {
const { issue, onChange, disabled } = props;
const { issue, onChange, disabled, onClose } = props;
return (
<div className="h-11 border-b-[0.5px] border-custom-border-200">
<PriorityDropdown
value={issue.priority}
onChange={(data) => onChange(issue, { priority: data },{changed_property:"priority",change_details:data})}
onChange={(data) => onChange(issue, { priority: data }, { changed_property: "priority", change_details: data })}
disabled={disabled}
buttonVariant="transparent-with-text"
buttonClassName="rounded-none text-left"
buttonContainerClassName="w-full"
onClose={onClose}
/>
</div>
);

View file

@ -9,12 +9,13 @@ import { TIssue } from "@plane/types";
type Props = {
issue: TIssue;
onClose: () => void;
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
disabled: boolean;
};
export const SpreadsheetStartDateColumn: React.FC<Props> = observer((props: Props) => {
const { issue, onChange, disabled } = props;
const { issue, onChange, disabled, onClose } = props;
return (
<div className="h-11 border-b-[0.5px] border-custom-border-200">
@ -36,6 +37,7 @@ export const SpreadsheetStartDateColumn: React.FC<Props> = observer((props: Prop
buttonVariant="transparent-with-text"
buttonClassName="rounded-none text-left"
buttonContainerClassName="w-full"
onClose={onClose}
/>
</div>
);

View file

@ -7,12 +7,13 @@ import { TIssue } from "@plane/types";
type Props = {
issue: TIssue;
onClose: () => void;
onChange: (issue: TIssue, data: Partial<TIssue>, updates: any) => void;
disabled: boolean;
};
export const SpreadsheetStateColumn: React.FC<Props> = observer((props) => {
const { issue, onChange, disabled } = props;
const { issue, onChange, disabled, onClose } = props;
return (
<div className="h-11 border-b-[0.5px] border-custom-border-200">
@ -24,6 +25,7 @@ export const SpreadsheetStateColumn: React.FC<Props> = observer((props) => {
buttonVariant="transparent-with-text"
buttonClassName="rounded-none text-left"
buttonContainerClassName="w-full"
onClose={onClose}
/>
</div>
);

View file

@ -0,0 +1,68 @@
import { useRef } from "react";
import { useRouter } from "next/router";
// types
import { IIssueDisplayProperties, TIssue } from "@plane/types";
import { EIssueActions } from "../types";
// constants
import { SPREADSHEET_PROPERTY_DETAILS } from "constants/spreadsheet";
// components
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
import { useEventTracker } from "hooks/store";
import { observer } from "mobx-react";
type Props = {
displayProperties: IIssueDisplayProperties;
issueDetail: TIssue;
disableUserActions: boolean;
property: keyof IIssueDisplayProperties;
handleIssues: (issue: TIssue, action: EIssueActions) => Promise<void>;
isEstimateEnabled: boolean;
};
export const IssueColumn = observer((props: Props) => {
const { displayProperties, issueDetail, disableUserActions, property, handleIssues, isEstimateEnabled } = props;
// router
const router = useRouter();
const tableCellRef = useRef<HTMLTableCellElement | null>(null);
const { captureIssueEvent } = useEventTracker();
const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true;
const { Column } = SPREADSHEET_PROPERTY_DETAILS[property];
return (
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey={property}
shouldRenderProperty={shouldRenderProperty}
>
<td
tabIndex={0}
className="h-11 w-full min-w-[8rem] bg-custom-background-100 text-sm after:absolute after:w-full after:bottom-[-1px] after:border after:border-custom-border-100 border-r-[1px] border-custom-border-100 focus:border-custom-primary-70"
ref={tableCellRef}
>
<Column
issue={issueDetail}
onChange={(issue: TIssue, data: Partial<TIssue>, updates: any) =>
handleIssues({ ...issue, ...data }, EIssueActions.UPDATE).then(() => {
captureIssueEvent({
eventName: "Issue updated",
payload: {
...issue,
...data,
element: "Spreadsheet layout",
},
updates: updates,
path: router.asPath,
});
})
}
disabled={disableUserActions}
onClose={() => {
tableCellRef?.current?.focus();
}}
/>
</td>
</WithDisplayPropertiesHOC>
);
});

View file

@ -4,14 +4,15 @@ import { observer } from "mobx-react-lite";
// icons
import { ChevronRight, MoreHorizontal } from "lucide-react";
// constants
import { SPREADSHEET_PROPERTY_DETAILS, SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet";
import { SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet";
// components
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
import { IssueColumn } from "./issue-column";
// ui
import { ControlLink, Tooltip } from "@plane/ui";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
import { useEventTracker, useIssueDetail, useProject } from "hooks/store";
import { useIssueDetail, useProject } from "hooks/store";
// helper
import { cn } from "helpers/common.helper";
// types
@ -51,7 +52,6 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
//hooks
const { getProjectById } = useProject();
const { peekIssue, setPeekIssue } = useIssueDetail();
const { captureIssueEvent } = useEventTracker();
// states
const [isMenuActive, setIsMenuActive] = useState(false);
const [isExpanded, setExpanded] = useState<boolean>(false);
@ -106,11 +106,12 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
{/* 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",
"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",
{
"border-b-[0.5px]": peekIssue?.issueId !== issueDetail.id,
}
)}
tabIndex={0}
>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="key">
<div
@ -149,11 +150,14 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
href={`/${workspaceSlug}/projects/${issueDetail.project_id}/issues/${issueId}`}
target="_blank"
onClick={() => handleIssuePeekOverview(issueDetail)}
className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100"
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">
<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>
@ -161,40 +165,16 @@ export const SpreadsheetIssueRow = observer((props: Props) => {
</ControlLink>
</td>
{/* Rest of the columns */}
{SPREADSHEET_PROPERTY_LIST.map((property) => {
const { Column } = SPREADSHEET_PROPERTY_DETAILS[property];
const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true;
return (
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey={property}
shouldRenderProperty={shouldRenderProperty}
>
<td className="h-11 w-full min-w-[8rem] bg-custom-background-100 text-sm after:absolute after:w-full after:bottom-[-1px] after:border after:border-custom-border-100 border-r-[1px] border-custom-border-100">
<Column
issue={issueDetail}
onChange={(issue: TIssue, data: Partial<TIssue>, updates: any) =>
handleIssues({ ...issue, ...data }, EIssueActions.UPDATE).then(() => {
captureIssueEvent({
eventName: "Issue updated",
payload: {
...issue,
...data,
element: "Spreadsheet layout",
},
updates: updates,
path: router.asPath,
});
})
}
disabled={disableUserActions}
/>
</td>
</WithDisplayPropertiesHOC>
);
})}
{SPREADSHEET_PROPERTY_LIST.map((property) => (
<IssueColumn
displayProperties={displayProperties}
issueDetail={issueDetail}
disableUserActions={disableUserActions}
property={property}
handleIssues={handleIssues}
isEstimateEnabled={isEstimateEnabled}
/>
))}
</tr>
{isExpanded &&

View file

@ -0,0 +1,46 @@
import { useRef } from "react";
//types
import { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/types";
//components
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
import { HeaderColumn } from "./columns/header-column";
import { observer } from "mobx-react";
interface Props {
displayProperties: IIssueDisplayProperties;
property: keyof IIssueDisplayProperties;
isEstimateEnabled: boolean;
displayFilters: IIssueDisplayFilterOptions;
handleDisplayFilterUpdate: (data: Partial<IIssueDisplayFilterOptions>) => void;
}
export const SpreadsheetHeaderColumn = observer((props: Props) => {
const { displayProperties, displayFilters, property, isEstimateEnabled, handleDisplayFilterUpdate } = props;
//hooks
const tableHeaderCellRef = useRef<HTMLTableCellElement | null>(null);
const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true;
return (
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey={property}
shouldRenderProperty={shouldRenderProperty}
>
<th
className="h-11 w-full min-w-[8rem] items-center bg-custom-background-90 text-sm font-medium px-4 py-1 border border-b-0 border-t-0 border-custom-border-100 focus:border-custom-primary-70"
ref={tableHeaderCellRef}
tabIndex={0}
>
<HeaderColumn
displayFilters={displayFilters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
property={property}
onClose={() => {
tableHeaderCellRef?.current?.focus();
}}
/>
</th>
</WithDisplayPropertiesHOC>
);
});

View file

@ -6,8 +6,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties } from "@plane/type
import { SPREADSHEET_PROPERTY_LIST } from "constants/spreadsheet";
// components
import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC";
import { SpreadsheetHeaderColumn } from "./columns/header-column";
import { SpreadsheetHeaderColumn } from "./spreadsheet-header-column";
interface Props {
displayProperties: IIssueDisplayProperties;
@ -22,7 +21,10 @@ export const SpreadsheetHeader = (props: Props) => {
return (
<thead className="sticky top-0 left-0 z-[1] border-b-[0.5px] border-custom-border-100">
<tr>
<th className="sticky left-0 z-[1] h-11 w-[28rem] flex items-center bg-custom-background-90 text-sm font-medium before:absolute before:h-full before:right-0 before:border-[0.5px] before:border-custom-border-100">
<th
className="sticky left-0 z-[1] h-11 w-[28rem] flex items-center bg-custom-background-90 text-sm font-medium before:absolute before:h-full before:right-0 before:border-[0.5px] before:border-custom-border-100"
tabIndex={-1}
>
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="key">
<span className="flex h-full w-24 flex-shrink-0 items-center px-4 py-2.5">
<span className="mr-1.5 text-custom-text-400">#</span>ID
@ -34,25 +36,15 @@ export const SpreadsheetHeader = (props: Props) => {
</span>
</th>
{SPREADSHEET_PROPERTY_LIST.map((property) => {
const shouldRenderProperty = property === "estimate" ? isEstimateEnabled : true;
return (
<WithDisplayPropertiesHOC
displayProperties={displayProperties}
displayPropertyKey={property}
shouldRenderProperty={shouldRenderProperty}
>
<th className="h-11 w-full min-w-[8rem] items-center bg-custom-background-90 text-sm font-medium px-4 py-1 border border-b-0 border-t-0 border-custom-border-100">
<SpreadsheetHeaderColumn
displayFilters={displayFilters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
property={property}
/>
</th>
</WithDisplayPropertiesHOC>
);
})}
{SPREADSHEET_PROPERTY_LIST.map((property) => (
<SpreadsheetHeaderColumn
property={property}
displayProperties={displayProperties}
displayFilters={displayFilters}
handleDisplayFilterUpdate={handleDisplayFilterUpdate}
isEstimateEnabled={isEstimateEnabled}
/>
))}
</tr>
</thead>
);

View file

@ -5,6 +5,7 @@ import { EIssueActions } from "../types";
//components
import { SpreadsheetIssueRow } from "./issue-row";
import { SpreadsheetHeader } from "./spreadsheet-header";
import { useTableKeyboardNavigation } from "hooks/use-table-keyboard-navigation";
type Props = {
displayProperties: IIssueDisplayProperties;
@ -35,8 +36,10 @@ export const SpreadsheetTable = observer((props: Props) => {
canEditProperties,
} = props;
const handleKeyBoardNavigation = useTableKeyboardNavigation();
return (
<table className="overflow-y-auto">
<table className="overflow-y-auto" onKeyDown={handleKeyBoardNavigation}>
<SpreadsheetHeader
displayProperties={displayProperties}
displayFilters={displayFilters}