UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: устойчивое выделение карточки после drag-drop

This commit is contained in:
DCCONSTRUCTIONS 2026-04-25 18:46:07 +03:00
parent eac010d3d4
commit f060d4dedd
8 changed files with 69 additions and 7 deletions

View File

@ -242,6 +242,7 @@
## Drag and drop
- Drag overlay использует акцентный контур.
- Во внутреннем kanban после успешного ручного переноса карточка остается в активной заливке `active_card_rgb` до выбора другой карточки/следующего переноса; сам drag-жест не открывает detail pane.
- Delete dropzone:
- без красного технического свечения и без red-tinted text/fill
- текст локализован

View File

@ -30,6 +30,7 @@ import { DeleteIssueModal } from "../../delete-issue-modal";
import { IssueLayoutHOC } from "../issue-layout-HOC";
import type { IQuickActionProps, TRenderQuickActions } from "../list/list-view-types";
//components
import type { GroupDropLocation } from "../utils";
import { getSourceFromDropPayload } from "../utils";
import { KanBan } from "./default";
import { KanBanSwimLanes } from "./swimlanes";
@ -127,6 +128,7 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa
// states
const [draggedIssueId, setDraggedIssueId] = useState<string | undefined>(undefined);
const [selectedIssueId, setSelectedIssueId] = useState<string | undefined>(undefined);
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const isEditingAllowed = allowPermissions(
@ -136,6 +138,18 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa
const handleOnDrop = useGroupIssuesDragNDrop(storeType, orderBy, group_by, sub_group_by);
const handleIssueDrop = useCallback(
async (source: GroupDropLocation, destination: GroupDropLocation) => {
if (cardVariant === "internal-contour") setSelectedIssueId(source.id);
await handleOnDrop(source, destination);
},
[cardVariant, handleOnDrop]
);
const handleClearSelectedIssue = useCallback(() => {
setSelectedIssueId(undefined);
}, []);
const canEditProperties = useCallback(
(projectId: string | undefined) => {
const isEditingAllowedBasedOnProject =
@ -305,7 +319,9 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa
addIssuesToView={addIssuesToView}
cardVariant={cardVariant}
scrollableContainerRef={scrollableContainerRef}
handleOnDrop={handleOnDrop}
handleOnDrop={handleIssueDrop}
selectedIssueId={selectedIssueId}
onClearSelectedIssue={handleClearSelectedIssue}
loadMoreIssues={fetchMoreIssues}
isEpic={isEpic}
/>

View File

@ -62,6 +62,8 @@ interface IssueBlockProps {
shouldRenderByDefault?: boolean;
isEpic?: boolean;
cardVariant?: TKanbanCardVariant;
selectedIssueId?: string;
onClearSelectedIssue?: () => void;
}
interface IssueDetailsBlockProps {
@ -198,6 +200,8 @@ export const KanbanIssueBlock = observer(function KanbanIssueBlock(props: IssueB
shouldRenderByDefault,
isEpic = false,
cardVariant = "default",
selectedIssueId,
onClearSelectedIssue,
} = props;
const cardRef = useRef<HTMLAnchorElement | null>(null);
@ -225,6 +229,14 @@ export const KanbanIssueBlock = observer(function KanbanIssueBlock(props: IssueB
const isDragAllowed = canDragIssuesInCurrentGrouping && !issue?.tempId && canEditIssueProperties;
const projectIdentifier = getProjectIdentifierById(issue?.project_id);
const isPeeked = issue ? getIsIssuePeeked(issue.id) : false;
const isDropSelected = cardVariant === "internal-contour" && selectedIssueId === issue?.id;
const handleIssueBlockClick = () => {
if (!issue) return;
if (selectedIssueId && selectedIssueId !== issue.id) onClearSelectedIssue?.();
handleIssuePeekOverview(issue);
};
const workItemLink = generateWorkItemLink({
workspaceSlug,
@ -318,7 +330,7 @@ export const KanbanIssueBlock = observer(function KanbanIssueBlock(props: IssueB
{ "z-[100]": isCurrentBlockDragging && cardVariant === "internal-contour" }
)}
data-card-variant={cardVariant}
onClick={() => handleIssuePeekOverview(issue)}
onClick={handleIssueBlockClick}
disabled={!!issue?.tempId}
>
<RenderIfVisible
@ -337,7 +349,7 @@ export const KanbanIssueBlock = observer(function KanbanIssueBlock(props: IssueB
quickActions={quickActions}
isReadOnly={!canEditIssueProperties}
isEpic={isEpic}
isActive={isPeeked || (cardVariant === "internal-contour" && isCurrentBlockDragging)}
isActive={isPeeked || isDropSelected || (cardVariant === "internal-contour" && isCurrentBlockDragging)}
cardVariant={cardVariant}
/>
</RenderIfVisible>

View File

@ -23,6 +23,8 @@ interface IssueBlocksListProps {
quickActions: TRenderQuickActions;
canEditProperties: (projectId: string | undefined) => boolean;
cardVariant?: TKanbanCardVariant;
selectedIssueId?: string;
onClearSelectedIssue?: () => void;
canDropOverIssue: boolean;
canDragIssuesInCurrentGrouping: boolean;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
@ -42,6 +44,8 @@ export const KanbanIssueBlocksList = observer(function KanbanIssueBlocksList(pro
quickActions,
canEditProperties,
cardVariant = "default",
selectedIssueId,
onClearSelectedIssue,
scrollableContainerRef,
isEpic = false,
} = props;
@ -70,6 +74,8 @@ export const KanbanIssueBlocksList = observer(function KanbanIssueBlocksList(pro
quickActions={quickActions}
draggableId={draggableId}
cardVariant={cardVariant}
selectedIssueId={selectedIssueId}
onClearSelectedIssue={onClearSelectedIssue}
canDropOverIssue={canDropOverIssue}
canDragIssuesInCurrentGrouping={canDragIssuesInCurrentGrouping}
canEditProperties={canEditProperties}

View File

@ -64,6 +64,8 @@ export interface IKanBan {
addIssuesToView?: (issueIds: string[]) => Promise<TIssue>;
canEditProperties: (projectId: string | undefined) => boolean;
cardVariant?: TKanbanCardVariant;
selectedIssueId?: string;
onClearSelectedIssue?: () => void;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise<void>;
showEmptyGroup?: boolean;
@ -91,6 +93,8 @@ export const KanBan = observer(function KanBan(props: IKanBan) {
addIssuesToView,
canEditProperties,
cardVariant = "default",
selectedIssueId,
onClearSelectedIssue,
scrollableContainerRef,
handleOnDrop,
showEmptyGroup = true,
@ -228,6 +232,8 @@ export const KanBan = observer(function KanBan(props: IKanBan) {
disableIssueCreation={disableIssueCreation}
canEditProperties={canEditProperties}
cardVariant={cardVariant}
selectedIssueId={selectedIssueId}
onClearSelectedIssue={onClearSelectedIssue}
scrollableContainerRef={scrollableContainerRef}
loadMoreIssues={loadMoreIssues}
handleOnDrop={handleOnDrop}

View File

@ -67,6 +67,8 @@ interface IKanbanGroup {
disableIssueCreation?: boolean;
canEditProperties: (projectId: string | undefined) => boolean;
cardVariant?: TKanbanCardVariant;
selectedIssueId?: string;
onClearSelectedIssue?: () => void;
groupByVisibilityToggle?: boolean;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise<void>;
@ -90,6 +92,8 @@ export const KanbanGroup = observer(function KanbanGroup(props: IKanbanGroup) {
quickActions,
canEditProperties,
cardVariant = "default",
selectedIssueId,
onClearSelectedIssue,
loadMoreIssues,
enableQuickIssueCreate,
disableIssueCreation,
@ -314,6 +318,8 @@ export const KanbanGroup = observer(function KanbanGroup(props: IKanbanGroup) {
quickActions={quickActions}
canEditProperties={canEditProperties}
cardVariant={cardVariant}
selectedIssueId={selectedIssueId}
onClearSelectedIssue={onClearSelectedIssue}
scrollableContainerRef={scrollableContainerRef}
canDropOverIssue={!canOverlayBeVisible}
canDragIssuesInCurrentGrouping={canDragIssuesInCurrentGrouping}

View File

@ -128,6 +128,8 @@ interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader {
quickActions: TRenderQuickActions;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
selectedIssueId?: string;
onClearSelectedIssue?: () => void;
showEmptyGroup: boolean;
updateIssue: ((projectId: string | null, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
}
@ -154,6 +156,8 @@ const SubGroupSwimlane = observer(function SubGroupSwimlane(props: ISubGroupSwim
quickActions,
quickAddCallback,
scrollableContainerRef,
selectedIssueId,
onClearSelectedIssue,
showEmptyGroup,
sub_group_by,
updateIssue,
@ -223,6 +227,8 @@ const SubGroupSwimlane = observer(function SubGroupSwimlane(props: ISubGroupSwim
addIssuesToView={addIssuesToView}
quickAddCallback={quickAddCallback}
scrollableContainerRef={scrollableContainerRef}
selectedIssueId={selectedIssueId}
onClearSelectedIssue={onClearSelectedIssue}
loadMoreIssues={loadMoreIssues}
handleOnDrop={handleOnDrop}
orderBy={orderBy}
@ -263,6 +269,8 @@ export interface IKanBanSwimLanes {
quickActions: TRenderQuickActions;
quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise<TIssue | undefined>;
scrollableContainerRef?: MutableRefObject<HTMLDivElement | null>;
selectedIssueId?: string;
onClearSelectedIssue?: () => void;
showEmptyGroup: boolean;
sub_group_by: TIssueGroupByOptions | undefined;
updateIssue: ((projectId: string | null, issueId: string, data: Partial<TIssue>) => Promise<void>) | undefined;
@ -291,6 +299,8 @@ export const KanBanSwimLanes = observer(function KanBanSwimLanes(props: IKanBanS
addIssuesToView,
quickAddCallback,
scrollableContainerRef,
selectedIssueId,
onClearSelectedIssue,
isEpic = false,
} = props;
// store hooks
@ -350,6 +360,8 @@ export const KanBanSwimLanes = observer(function KanBanSwimLanes(props: IKanBanS
cardVariant={cardVariant}
quickAddCallback={quickAddCallback}
scrollableContainerRef={scrollableContainerRef}
selectedIssueId={selectedIssueId}
onClearSelectedIssue={onClearSelectedIssue}
isEpic={isEpic}
/>
)}

View File

@ -355,12 +355,15 @@ export const highlightIssueOnDrop = (
) => {
setTimeout(async () => {
const sourceElementId = elementId ?? "";
const sourceElement = document.getElementById(sourceElementId);
let sourceElement = document.getElementById(sourceElementId);
for (let attempt = 0; !sourceElement && attempt < 8; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 100));
sourceElement = document.getElementById(sourceElementId);
}
if (sourceElement?.dataset.cardVariant === "internal-contour") {
sourceElement.classList.remove(HIGHLIGHT_CLASS, HIGHLIGHT_WITH_LINE);
sourceElement.classList.add(NODEDC_DROP_FILL_HIGHLIGHT_CLASS);
window.setTimeout(() => sourceElement.classList.remove(NODEDC_DROP_FILL_HIGHLIGHT_CLASS), 1600);
sourceElement.classList.remove(HIGHLIGHT_CLASS, HIGHLIGHT_WITH_LINE, NODEDC_DROP_FILL_HIGHLIGHT_CLASS);
if (shouldScrollIntoView)
await scrollIntoView(sourceElement, { behavior: "smooth", block: "center", duration: 1500 });