+
{prependTitleElement && {prependTitleElement}}
- {title}
+ {title}
{appendTitleElement && (
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..9c65c5bc18b
--- /dev/null
+++ b/apps/web/core/components/issues/issue-layouts/list/block.logic.ts
@@ -0,0 +1,36 @@
+/**
+ * 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;
+
+ 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/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.name}
+
+
+ {isEpic && displayProperties && (
+
!!properties.sub_issue_count}
>
-
-
+
+
)}
-
- {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/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"]
}
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 (