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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -355,12 +355,15 @@ export const highlightIssueOnDrop = (
) => { ) => {
setTimeout(async () => { setTimeout(async () => {
const sourceElementId = elementId ?? ""; 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") { if (sourceElement?.dataset.cardVariant === "internal-contour") {
sourceElement.classList.remove(HIGHLIGHT_CLASS, HIGHLIGHT_WITH_LINE); sourceElement.classList.remove(HIGHLIGHT_CLASS, HIGHLIGHT_WITH_LINE, NODEDC_DROP_FILL_HIGHLIGHT_CLASS);
sourceElement.classList.add(NODEDC_DROP_FILL_HIGHLIGHT_CLASS);
window.setTimeout(() => sourceElement.classList.remove(NODEDC_DROP_FILL_HIGHLIGHT_CLASS), 1600);
if (shouldScrollIntoView) if (shouldScrollIntoView)
await scrollIntoView(sourceElement, { behavior: "smooth", block: "center", duration: 1500 }); await scrollIntoView(sourceElement, { behavior: "smooth", block: "center", duration: 1500 });