From 116941817acdb8375aa5d56350c4abf550e89129 Mon Sep 17 00:00:00 2001 From: Seth-Wadsworth Date: Sun, 1 Mar 2026 13:40:16 -0700 Subject: [PATCH 1/3] Fix text selection in list and issue blocks; isolate mouse events and update ControlLink behavior --- .../core/components/core/list/list-item.tsx | 8 +- .../issues/issue-layouts/list/block.tsx | 214 +++++++++++------- packages/ui/src/control-link/control-link.tsx | 34 ++- 3 files changed, 165 insertions(+), 91 deletions(-) diff --git a/apps/web/core/components/core/list/list-item.tsx b/apps/web/core/components/core/list/list-item.tsx index fa48c8ebc65..9cc2cd027e1 100644 --- a/apps/web/core/components/core/list/list-item.tsx +++ b/apps/web/core/components/core/list/list-item.tsx @@ -74,20 +74,20 @@ export function ListItem(props: IListItemProps) { className )} > -
+
-
+
{prependTitleElement && {prependTitleElement}} - {title} + {title}
{appendTitleElement && ( diff --git a/apps/web/core/components/issues/issue-layouts/list/block.tsx b/apps/web/core/components/issues/issue-layouts/list/block.tsx index 1d93d53b10e..fbb8b3fc3d3 100644 --- a/apps/web/core/components/issues/issue-layouts/list/block.tsx +++ b/apps/web/core/components/issues/issue-layouts/list/block.tsx @@ -66,7 +66,7 @@ export const IssueBlock = observer(function IssueBlock(props: IssueBlockProps) { displayProperties, canEditProperties, nestingLevel, - spacingLeft = 14, + //spacingLeft = 14, isExpanded, setExpanded, selectionHelpers, @@ -121,7 +121,10 @@ export const IssueBlock = observer(function IssueBlock(props: IssueBlockProps) { return combine( draggable({ element, - canDrag: () => isDraggingAllowed, + canDrag: () => { + if (window.getSelection()?.toString()) return false; + return isDraggingAllowed; + }, getInitialData: () => ({ id: issueId, type: "ISSUE", groupId }), onDragStart: () => { setIsCurrentBlockDragging(true); @@ -138,10 +141,10 @@ export const IssueBlock = observer(function IssueBlock(props: IssueBlockProps) { const projectIdentifier = getProjectIdentifierById(issue.project_id); const isIssueSelected = selectionHelpers.getIsEntitySelected(issue.id); const isIssueActive = selectionHelpers.getIsEntityActive(issue.id); - const isSubIssue = nestingLevel !== 0; + //const isSubIssue = nestingLevel !== 0; const canSelectIssues = canEditIssueProperties && !selectionHelpers.isSelectionDisabled; - const marginLeft = `${spacingLeft}px`; + //const marginLeft = `${spacingLeft}px`; const handleToggleExpand = (e: MouseEvent) => { e.stopPropagation(); @@ -151,7 +154,7 @@ export const IssueBlock = observer(function IssueBlock(props: IssueBlockProps) { } else { setExpanded((prevState) => { if (!prevState && workspaceSlug && issue && issue.project_id) - subIssuesStore.fetchSubIssues(workspaceSlug.toString(), issue.project_id, issue.id); + void subIssuesStore.fetchSubIssues(workspaceSlug.toString(), issue.project_id, issue.id); return !prevState; }); } @@ -177,12 +180,20 @@ export const IssueBlock = observer(function IssueBlock(props: IssueBlockProps) { handleIssuePeekOverview(issue)} + onClick={(e) => { + const selection = window.getSelection(); + if (selection && selection.toString().length > 0) { + e.preventDefault(); + e.stopPropagation(); + return; + } + + handleIssuePeekOverview(issue); + }} className="w-full cursor-pointer" disabled={!!issue?.tempId || issue?.is_draft} > -
-
-
- {/* select checkbox */} - {projectId && canSelectIssues && !isEpic && ( - - Only work items within the current -
- project can be selected. - +
+
+
+
+ {/* eslint-disable jsx-a11y/no-static-element-interactions */} + {/* eslint-disable jsx-a11y/click-events-have-key-events */} +
{ + if (window.getSelection()?.toString()) { + e.preventDefault(); + e.stopPropagation(); } - disabled={issue.project_id === projectId} - > -
- + }} + > + {/* select checkbox */} + {projectId && canSelectIssues && !isEpic && ( + + Only work items within the current +
+ project can be selected. + + } + disabled={issue.project_id === projectId} + > +
+ +
+
+ )} + {displayProperties && (displayProperties.key || displayProperties.issue_type) && ( +
+ {issue.project_id && ( + + )}
- - )} - {displayProperties && (displayProperties.key || displayProperties.issue_type) && ( -
- {issue.project_id && ( - + )} + + {/* sub-issues chevron */} +
+ {subIssuesCount > 0 && !isEpic && ( + )}
- )} - {/* sub-issues chevron */} -
- {subIssuesCount > 0 && !isEpic && ( - + + )}
- - {issue?.tempId !== undefined && ( -
- )}
- - -

{issue.name}

-
- {isEpic && displayProperties && ( - !!properties.sub_issue_count} - > - - - )}
{!issue?.tempId && (
)}
-
+
e.stopPropagation()}> {!issue?.tempId ? ( <> { + if (window.getSelection()?.toString()) { + e.preventDefault(); + e.stopPropagation(); + return; + } e.preventDefault(); e.stopPropagation(); }} diff --git a/packages/ui/src/control-link/control-link.tsx b/packages/ui/src/control-link/control-link.tsx index 39249fb0b05..47d66e75c41 100644 --- a/packages/ui/src/control-link/control-link.tsx +++ b/packages/ui/src/control-link/control-link.tsx @@ -14,27 +14,47 @@ export type TControlLink = React.AnchorHTMLAttributes & { disabled?: boolean; className?: string; draggable?: boolean; + selectable?: boolean; }; export const ControlLink = React.forwardRef(function ControlLink( props: TControlLink, ref: React.ForwardedRef ) { - const { href, onClick, children, target = "_blank", disabled = false, className, draggable = false, ...rest } = props; + const { + href, + onClick, + children, + target = "_blank", + disabled = false, + className, + draggable = false, + selectable, + ...rest + } = props; const LEFT_CLICK_EVENT_CODE = 0; const handleOnClick = (event: React.MouseEvent) => { + if (window.getSelection()?.toString()) { + return; + } + const clickCondition = (event.metaKey || event.ctrlKey) && event.button === LEFT_CLICK_EVENT_CODE; if (!clickCondition) { event.preventDefault(); onClick(event); } }; + const handleMouseDown = (event: React.MouseEvent) => { + if (window.getSelection()?.toString()) { + event.stopPropagation(); + } + }; // if disabled but still has a ref or a className then it has to be rendered without a href if (disabled && (ref || className)) return ( - + } className={className} href={href ?? "#"}> {children} ); @@ -42,11 +62,21 @@ export const ControlLink = React.forwardRef(function ControlLink( // else if just disabled return without the parent wrapper if (disabled) return <>{children}; + // + if (selectable) { + return ( + } className={className} {...rest}> + {children} + + ); + } + return ( Date: Fri, 6 Mar 2026 08:45:46 -0700 Subject: [PATCH 2/3] List block logic and Vitest for selection, drag, expand, toast behavior --- .../issues/issue-layouts/list/block.logic.ts | 23 ++++++ packages/codemods/tests/block.logic.test.ts | 71 +++++++++++++++++++ packages/codemods/tsconfig.json | 3 +- 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 apps/web/core/components/issues/issue-layouts/list/block.logic.ts create mode 100644 packages/codemods/tests/block.logic.test.ts diff --git a/apps/web/core/components/issues/issue-layouts/list/block.logic.ts b/apps/web/core/components/issues/issue-layouts/list/block.logic.ts new file mode 100644 index 00000000000..889a7e92180 --- /dev/null +++ b/apps/web/core/components/issues/issue-layouts/list/block.logic.ts @@ -0,0 +1,23 @@ +export function shouldSuppressEvent(selectionText: string | null): boolean { + return !!selectionText && selectionText.length > 0; +} + +export function canDragBasedOnSelection(selectionText: string | null, isAllowed: boolean) { + if (selectionText) return false; + return isAllowed; +} + +export function nextExpandState(nestingLevel: number, isExpanded: boolean) { + return nestingLevel >= 3 ? isExpanded : !isExpanded; +} + +export function getDragDisallowedToast(canDrag: boolean, canEdit: boolean) { + if (canDrag) return null; + + return { + title: "Cannot move work item", + message: canEdit + ? "Drag and drop is disabled for the current grouping" + : "You are not allowed to move this work item", + }; +} diff --git a/packages/codemods/tests/block.logic.test.ts b/packages/codemods/tests/block.logic.test.ts new file mode 100644 index 00000000000..ee4e8f33991 --- /dev/null +++ b/packages/codemods/tests/block.logic.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { + shouldSuppressEvent, + canDragBasedOnSelection, + nextExpandState, + getDragDisallowedToast, +} from "../../../apps/web/core/components/issues/issue-layouts/list/block.logic"; + +/** + * Tests for logic extracted from the list block component + */ +describe("block.logic", () => { + describe("shouldSuppressEvent", () => { + it("returns true when selection text exists", () => { + expect(shouldSuppressEvent("hello")).toBe(true); + }); + + it("returns false when selection text is empty", () => { + expect(shouldSuppressEvent("")).toBe(false); + }); + + it("returns false when selection text is null", () => { + expect(shouldSuppressEvent(null)).toBe(false); + }); + }); + describe("canDragBasedOnSelection", () => { + it("disallows drag when selection text exists", () => { + expect(canDragBasedOnSelection("selected text", true)).toBe(false); + }); + + it("allows drag when no selection and isAllowed is true", () => { + expect(canDragBasedOnSelection(null, true)).toBe(true); + }); + + it("disallows drag when no selection but isAllowed is false", () => { + expect(canDragBasedOnSelection(null, false)).toBe(false); + }); + }); + describe("nextExpandState", () => { + it("toggles expand state when nesting level < 3", () => { + expect(nextExpandState(1, false)).toBe(true); + expect(nextExpandState(2, true)).toBe(false); + }); + + it("keeps expand state when nesting level >= 3", () => { + expect(nextExpandState(3, false)).toBe(false); + expect(nextExpandState(4, true)).toBe(true); + }); + }); + describe("getDragDisallowedToast", () => { + it("returns null when drag is allowed", () => { + expect(getDragDisallowedToast(true, true)).toBeNull(); + }); + + it("returns correct toast when drag is disallowed but user can edit", () => { + const toast = getDragDisallowedToast(false, true); + expect(toast).toEqual({ + title: "Cannot move work item", + message: "Drag and drop is disabled for the current grouping", + }); + }); + + it("returns correct toast when drag is disallowed and user cannot edit", () => { + const toast = getDragDisallowedToast(false, false); + expect(toast).toEqual({ + title: "Cannot move work item", + message: "You are not allowed to move this work item", + }); + }); + }); +}); diff --git a/packages/codemods/tsconfig.json b/packages/codemods/tsconfig.json index 07945fef928..7a38d0176a4 100644 --- a/packages/codemods/tsconfig.json +++ b/packages/codemods/tsconfig.json @@ -9,5 +9,6 @@ "noUncheckedIndexedAccess": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true - } + }, + "include": ["src", "tests", "../../apps/web"] } From c3ea967c1baea437c5d83600dd183f15da153f6c Mon Sep 17 00:00:00 2001 From: Seth-Wadsworth Date: Fri, 6 Mar 2026 08:50:55 -0700 Subject: [PATCH 3/3] comments for block.logic.ts --- .../issues/issue-layouts/list/block.logic.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/web/core/components/issues/issue-layouts/list/block.logic.ts b/apps/web/core/components/issues/issue-layouts/list/block.logic.ts index 889a7e92180..9c65c5bc18b 100644 --- a/apps/web/core/components/issues/issue-layouts/list/block.logic.ts +++ b/apps/web/core/components/issues/issue-layouts/list/block.logic.ts @@ -1,16 +1,29 @@ +/** + * Determines wheter a pointer/drag event should be suppressed. + * If user has any text selected. we avoid triggering drag behavior + */ export function shouldSuppressEvent(selectionText: string | null): boolean { return !!selectionText && selectionText.length > 0; } - +/** + * If the user has selected text, dragging is always disabled + */ export function canDragBasedOnSelection(selectionText: string | null, isAllowed: boolean) { if (selectionText) return false; return isAllowed; } +/** + * Computes the next expand/collapse state for a list item + */ export function nextExpandState(nestingLevel: number, isExpanded: boolean) { return nestingLevel >= 3 ? isExpanded : !isExpanded; } +/** + * If dragging is allowed returns null + * Otherwise the message depends on edit permissions + */ export function getDragDisallowedToast(canDrag: boolean, canEdit: boolean) { if (canDrag) return null;