Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
f72891ff20 chore(issues): performance, accessibility, and code quality fixes across all views
Performance:
- Convert O(n³) nested includes() to Map index in swimlane drag lookup
- Convert O(n²) array.includes() to Set in list-view and drag-utils
- Convert O(n*m) array.find() to Map in cache-helpers and delete-cache
- Combine 9 filter().map() chains into single-pass flatMap
- Memoize JSX trigger props in batch-action-toolbar, board-card, label-picker,
  issue-actions-dropdown, issue-actions-context-menu
- Memoize object prop in board-view
- Convert useState to useRef for non-rendered highlightedIndex in property-picker
- Extract stable EMPTY_STATUSES constant to prevent memo invalidation in swimlane-view

Accessibility:
- Add aria-label to 6 unlabeled controls (filter inputs, color picker, checkboxes)
- Add role="presentation" to non-interactive event-stopping wrapper in board-card
- Add role="group" to comment edit containers in comment-card
- Convert span[role="button"] to semantic <button> in issues-header

Code quality:
- Replace px-X py-X with p-X shorthand (4 files)
- Remove 2 unused exports (BOARD_COL_WIDTH, getIssueGroupId)
- Destructure useNavigation() in issue-detail
- Add i18n keys for new aria-labels (en + zh-Hans)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 16:01:04 +08:00
23 changed files with 193 additions and 134 deletions

View File

@@ -28,12 +28,13 @@ export function findIssueLocation(
resp: ListIssuesCache,
id: string,
): { status: IssueStatus; issue: Issue } | null {
const index = new Map<string, { status: IssueStatus; issue: Issue }>();
for (const status of PAGINATED_STATUSES) {
const bucket = resp.byStatus[status];
const found = bucket?.issues.find((i) => i.id === id);
if (found) return { status, issue: found };
if (!bucket) continue;
for (const issue of bucket.issues) index.set(issue.id, { status, issue });
}
return null;
return index.get(id) ?? null;
}
/** Add an issue to its status bucket (no-op if already present). */

View File

@@ -62,7 +62,9 @@ export function collectDeletedIssueCacheMetadata(
for (const [key, data] of qc.getQueriesData<Issue[]>({
queryKey: [...issueKeys.all(wsId), "children"],
})) {
const child = data?.find((issue) => issue.id === issueId);
if (!data) continue;
const childMap = new Map(data.map((issue) => [issue.id, issue]));
const child = childMap.get(issueId);
if (!child) continue;
collectParentId(parentIssueIds, child.parent_issue_id);
collectParentId(parentIssueIds, parentIdFromChildrenKey(key));

View File

@@ -1,6 +1,6 @@
"use client";
import { useRef, useState, type ReactElement } from "react";
import { useMemo, useRef, useState, type ReactElement } from "react";
import type { Issue } from "@multica/core/types";
import {
ContextMenu,
@@ -36,6 +36,23 @@ export function IssueActionsContextMenu({
clickPosRef.current = { x: e.clientX, y: e.clientY };
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- reads ref at open time
const assigneeAnchor = useMemo(
() => (
<span
aria-hidden
className="pointer-events-none fixed"
style={{
left: clickPosRef.current.x,
top: clickPosRef.current.y,
width: 0,
height: 0,
}}
/>
),
[assigneeOpen],
);
return (
<>
<ContextMenu>
@@ -62,18 +79,7 @@ export function IssueActionsContextMenu({
onUpdate={actions.updateField}
open={assigneeOpen}
onOpenChange={setAssigneeOpen}
triggerRender={
<span
aria-hidden
className="pointer-events-none fixed"
style={{
left: clickPosRef.current.x,
top: clickPosRef.current.y,
width: 0,
height: 0,
}}
/>
}
triggerRender={assigneeAnchor}
trigger={<span />}
align="start"
/>

View File

@@ -14,6 +14,10 @@ import {
} from "./issue-actions-menu-items";
import { AssigneePicker } from "../components/pickers";
const ASSIGNEE_PICKER_ANCHOR = (
<span aria-hidden className="pointer-events-none absolute inset-0" />
);
interface IssueActionsDropdownProps {
issue: Issue;
/** A single React element cloned by Base UI as the trigger (via `render` prop). */
@@ -60,12 +64,7 @@ export function IssueActionsDropdown({
onUpdate={actions.updateField}
open={assigneeOpen}
onOpenChange={setAssigneeOpen}
triggerRender={
<span
aria-hidden
className="pointer-events-none absolute inset-0"
/>
}
triggerRender={ASSIGNEE_PICKER_ANCHOR}
trigger={<span />}
align={align}
/>

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { X, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@multica/ui/components/ui/button";
@@ -45,6 +45,11 @@ export function BatchActionToolbar({
const batchDelete = useBatchDeleteIssues();
const loading = batchUpdate.isPending || batchDelete.isPending;
const pickerTrigger = useMemo(
() => <Button variant="ghost" size="sm" disabled={loading} />,
[loading],
);
if (count === 0) return null;
const ids = Array.from(selectedIds);
@@ -105,7 +110,7 @@ export function BatchActionToolbar({
onUpdate={handleBatchUpdate}
open={statusOpen}
onOpenChange={setStatusOpen}
triggerRender={<Button variant="ghost" size="sm" disabled={loading} />}
triggerRender={pickerTrigger}
trigger={t(($) => $.batch.status)}
align="center"
/>
@@ -116,7 +121,7 @@ export function BatchActionToolbar({
onUpdate={handleBatchUpdate}
open={priorityOpen}
onOpenChange={setPriorityOpen}
triggerRender={<Button variant="ghost" size="sm" disabled={loading} />}
triggerRender={pickerTrigger}
trigger={t(($) => $.batch.priority)}
align="center"
/>
@@ -128,7 +133,7 @@ export function BatchActionToolbar({
onUpdate={handleBatchUpdate}
open={assigneeOpen}
onOpenChange={setAssigneeOpen}
triggerRender={<Button variant="ghost" size="sm" disabled={loading} />}
triggerRender={pickerTrigger}
trigger={t(($) => $.batch.assignee)}
align="center"
/>

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, memo } from "react";
import { useCallback, memo, useMemo } from "react";
import { AppLink } from "../../navigation";
import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable";
import type { AnimateLayoutChanges } from "@dnd-kit/sortable";
@@ -52,7 +52,7 @@ function PickerWrapper({ children, className }: { children: React.ReactNode; cla
e.preventDefault();
};
return (
<div onClick={stop} onMouseDown={stop} onPointerDown={stop} className={className}>
<div role="presentation" onClick={stop} onMouseDown={stop} onPointerDown={stop} className={className}>
{children}
</div>
);
@@ -115,21 +115,25 @@ export const BoardCardContent = memo(function BoardCardContent({
: null;
const priorityLabel = t(($) => $.priority[issue.priority]);
const priorityTrigger = useMemo(
() => (
<button
type="button"
aria-label={priorityLabel}
className="inline-flex items-center justify-center rounded hover:bg-muted/60"
>
<PriorityIcon priority={issue.priority} />
</button>
),
[priorityLabel, issue.priority],
);
const priorityIconNode = showPriority ? (
editable ? (
<PickerWrapper>
<PriorityPicker
priority={issue.priority}
onUpdate={handleUpdate}
triggerRender={
<button
type="button"
aria-label={priorityLabel}
className="inline-flex items-center justify-center rounded hover:bg-muted/60"
>
<PriorityIcon priority={issue.priority} />
</button>
}
triggerRender={priorityTrigger}
/>
</PickerWrapper>
) : (

View File

@@ -27,7 +27,7 @@ import { ActorAvatar } from "../../common/actor-avatar";
// cannot be faithfully replicated in JavaScript (ICU/V8). Showing an
// inaccurate indicator is worse than showing none.
export const BOARD_COL_WIDTH = 280;
const BOARD_COL_WIDTH = 280;
export const BOARD_CARD_WIDTH = BOARD_COL_WIDTH - 16 - 8; // col(280) - col p-2(16) - droppable p-1(8)
export interface BoardColumnGroup {

View File

@@ -32,6 +32,7 @@ import {
assigneeGroupId,
buildColumns,
computePosition,
buildColumnIndex,
findColumn,
issueMatchesGroup,
getMoveUpdates,
@@ -146,9 +147,13 @@ export function BoardView({
? t(($) => $.board.ordered_by, { field: t(($) => $.display[`sort_${sortFieldKey}` as keyof typeof $.display]) })
: null;
const { getActorName } = useActorName();
const myIssuesOpts = myIssuesScope
? { scope: myIssuesScope, filter: myIssuesFilter ?? {} }
: undefined;
const myIssuesOpts = useMemo(
() =>
myIssuesScope
? { scope: myIssuesScope, filter: myIssuesFilter ?? {} }
: undefined,
[myIssuesScope, myIssuesFilter],
);
const groupedIssues = useMemo(
() =>
grouping === "assignee" && assigneeGroups
@@ -281,8 +286,9 @@ export function BoardView({
const overId = over.id as string;
setColumns((prev) => {
const activeCol = findColumn(prev, activeId, groupIds);
const overCol = findColumn(prev, overId, groupIds);
const idx = buildColumnIndex(prev);
const activeCol = findColumn(idx, activeId, groupIds);
const overCol = findColumn(idx, overId, groupIds);
if (!activeCol || !overCol || activeCol === overCol) return prev;
if (sortBy !== "position") return prev;
@@ -317,8 +323,9 @@ export function BoardView({
const overId = over.id as string;
const cols = columnsRef.current;
const activeCol = findColumn(cols, activeId, groupIds);
const overCol = findColumn(cols, overId, groupIds);
const colsIdx = buildColumnIndex(cols);
const activeCol = findColumn(colsIdx, activeId, groupIds);
const overCol = findColumn(colsIdx, overId, groupIds);
if (!activeCol || !overCol) {
resetColumns();
return;
@@ -337,8 +344,9 @@ export function BoardView({
}
}
const finalIdx = finalColumns === cols ? colsIdx : buildColumnIndex(finalColumns);
const finalCol = sortBy === "position"
? findColumn(finalColumns, activeId, groupIds)
? findColumn(finalIdx, activeId, groupIds)
: overCol;
if (!finalCol) {
resetColumns();

View File

@@ -193,8 +193,7 @@ function initialStandaloneAttachmentIds(entry: TimelineEntry): Set<string> {
const content = entry.content ?? "";
return new Set(
(entry.attachments ?? [])
.filter((attachment) => !content.includes(attachment.url))
.map((attachment) => attachment.id),
.flatMap((attachment) => content.includes(attachment.url) ? [] : [attachment.id]),
);
}
@@ -417,6 +416,7 @@ function CommentRow({
{edit.editing ? (
<div
role="group"
{...edit.dropZoneProps}
className="relative mt-1.5 pl-8"
onKeyDown={(e) => { if (e.key === "Escape") edit.cancelEdit(); }}
@@ -656,6 +656,7 @@ function CommentCardImpl({
<div className="px-4 pb-3">
{edit.editing ? (
<div
role="group"
{...edit.dropZoneProps}
className="relative pl-10"
onKeyDown={(e) => { if (e.key === "Escape") edit.cancelEdit(); }}

View File

@@ -74,8 +74,7 @@ function CommentInput({ issueId, onSubmit }: CommentInputProps) {
if (!content || submitting) return;
// Only send attachment IDs for uploads still present in the content.
const activeIds = pendingAttachments
.filter((a) => content.includes(a.url))
.map((a) => a.id);
.flatMap((a) => content.includes(a.url) ? [a.id] : []);
setSubmitting(true);
try {
await onSubmit(content, activeIds.length > 0 ? activeIds : undefined);

View File

@@ -157,7 +157,7 @@ export function ExecutionLogSection({ issueId }: ExecutionLogSectionProps) {
<button
type="button"
onClick={() => setShowPast(!showPast)}
className="flex w-full items-center gap-1 rounded px-1 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent/40 hover:text-foreground"
className="flex w-full items-center gap-1 rounded p-1 text-xs text-muted-foreground transition-colors hover:bg-accent/40 hover:text-foreground"
>
<ChevronRight
className={`!size-3 shrink-0 stroke-[2.5] transition-transform ${

View File

@@ -659,7 +659,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
const { t } = useT("issues");
const timeAgo = useTimeAgo();
const id = issueId;
const router = useNavigation();
const { push } = useNavigation();
const user = useAuthStore((s) => s.user);
const workspace = useCurrentWorkspace();
const paths = useWorkspacePaths();
@@ -1230,7 +1230,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
</div>
<div className="flex flex-1 min-h-0">
<div className="flex-1 overflow-y-auto">
<div className="mx-auto w-full max-w-4xl px-8 py-8 space-y-6">
<div className="mx-auto w-full max-w-4xl p-8 space-y-6">
<Skeleton className="h-8 w-3/4" />
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
@@ -1275,7 +1275,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
<p>{t(($) => $.detail.not_found)}</p>
{!onDelete && (
<Button variant="outline" size="sm" onClick={() => router.push(paths.issues())}>
<Button variant="outline" size="sm" onClick={() => push(paths.issues())}>
<ChevronLeft className="mr-1 h-3.5 w-3.5" />
{t(($) => $.detail.back_to_issues)}
</Button>
@@ -1370,7 +1370,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
icon the resulting picker uses, so the dropdown reads
as a preview of what will show up below. */}
<PopoverContent align="start" className="w-44 p-1">
{OPTIONAL_PROP_KEYS.filter((k) => !visibleOptionalProps.has(k)).map((k) => (
{OPTIONAL_PROP_KEYS.flatMap((k) => visibleOptionalProps.has(k) ? [] : [(
<button
key={k}
type="button"
@@ -1396,7 +1396,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
{k === "labels" && t(($) => $.detail.prop_labels)}
</span>
</button>
))}
)])}
</PopoverContent>
</Popover>
</div>
@@ -1742,7 +1742,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
data-tab-scroll-root
className="relative flex-1 overflow-y-auto"
>
<div className="mx-auto w-full max-w-4xl px-8 py-8">
<div className="mx-auto w-full max-w-4xl p-8">
<TitleEditor
key={`title-${id}`}
defaultValue={issue.title}
@@ -1790,8 +1790,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
// so they appear in `issueAttachments` after refresh and the
// editor's text/code preview keeps working past reload.
const ids = descPendingAttachments
.filter((a) => md.includes(a.url))
.map((a) => a.id);
.flatMap((a) => md.includes(a.url) ? [a.id] : []);
handleUpdateField({ description: md, attachment_ids: ids.length > 0 ? ids : undefined });
}}
onUploadFile={handleDescriptionUpload}

View File

@@ -219,6 +219,7 @@ function ActorSubContent({
placeholder={t(($) => $.filters.placeholder)}
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
autoFocus
aria-label={t(($) => $.filters.filter_actors_aria)}
/>
</div>
@@ -376,6 +377,7 @@ function ProjectSubContent({
placeholder={t(($) => $.filters.placeholder)}
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
autoFocus
aria-label={t(($) => $.filters.filter_projects_aria)}
/>
</div>
@@ -459,6 +461,7 @@ function LabelSubContent({
placeholder={t(($) => $.filters.placeholder)}
className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none"
autoFocus
aria-label={t(($) => $.filters.filter_labels_aria)}
/>
</div>
@@ -675,15 +678,14 @@ export function IssueDisplayControls({
? t(($) => $.filters.active_count, { count: activeFilterCount })
: t(($) => $.filters.tooltip)}
{hasActiveFilters && (
<span
role="button"
tabIndex={-1}
<button
type="button"
className="-mr-1 ml-0.5 rounded-sm p-0.5 hover:bg-white/20"
onClick={(e) => { e.preventDefault(); e.stopPropagation(); act.clearFilters(); }}
onPointerDown={(e) => e.stopPropagation()}
>
<X className="size-3" />
</span>
</button>
)}
</Button>
}

View File

@@ -288,6 +288,7 @@ function ColorPalette({
value={value}
onChange={(e) => onChange(e.target.value)}
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
aria-label={t(($) => $.labels_panel.pick_color_aria)}
/>
</label>
{!compact && (

View File

@@ -90,6 +90,7 @@ function ListRowContent({
type="checkbox"
checked={selected}
onChange={() => toggle(issue.id)}
aria-label={issue.identifier}
className={`absolute inset-0 cursor-pointer accent-primary ${
selected ? "" : "hidden group-hover/row:block"
}`}

View File

@@ -33,6 +33,7 @@ import {
statusGroupId,
buildColumns,
computePosition,
buildColumnIndex,
findColumn,
issueMatchesGroup,
getMoveUpdates,
@@ -177,8 +178,9 @@ export function ListView({
const overId = over.id as string;
setColumns((prev) => {
const activeCol = findColumn(prev, activeId, groupIds);
const overCol = findColumn(prev, overId, groupIds);
const idx = buildColumnIndex(prev);
const activeCol = findColumn(idx, activeId, groupIds);
const overCol = findColumn(idx, overId, groupIds);
if (!activeCol || !overCol || activeCol === overCol) return prev;
if (sortBy !== "position") return prev;
@@ -213,8 +215,9 @@ export function ListView({
const overId = over.id as string;
const cols = columnsRef.current;
const activeCol = findColumn(cols, activeId, groupIds);
const overCol = findColumn(cols, overId, groupIds);
const colsIdx = buildColumnIndex(cols);
const activeCol = findColumn(colsIdx, activeId, groupIds);
const overCol = findColumn(colsIdx, overId, groupIds);
if (!activeCol || !overCol) {
resetColumns();
return;
@@ -232,8 +235,9 @@ export function ListView({
}
}
const finalIdx = finalColumns === cols ? colsIdx : buildColumnIndex(finalColumns);
const finalCol = sortBy === "position"
? findColumn(finalColumns, activeId, groupIds)
? findColumn(finalIdx, activeId, groupIds)
: overCol;
if (!finalCol) {
resetColumns();
@@ -288,9 +292,11 @@ export function ListView({
value={expandedStatuses}
onValueChange={(value: string[]) => {
if (isDraggingRef.current) return;
const expandedSet = new Set(expandedStatuses);
const valueSet = new Set(value);
for (const status of visibleStatuses) {
const wasExpanded = expandedStatuses.includes(status);
const isExpanded = value.includes(status);
const wasExpanded = expandedSet.has(status);
const isExpanded = valueSet.has(status);
if (wasExpanded !== isExpanded) {
toggleListCollapsed(status as IssueStatus);
}
@@ -425,6 +431,7 @@ function StatusAccordionItem({
select(issueIds);
}
}}
aria-label={`Select all ${status} issues`}
className="cursor-pointer accent-primary"
/>
</div>

View File

@@ -22,6 +22,10 @@ import {
} from "./property-picker";
import { useT } from "../../../i18n";
const LABEL_TRIGGER_RENDER = (
<div className="flex flex-wrap items-center gap-1 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors" />
);
interface LabelPickerProps {
issueId: string;
/** Optional controlled open state (for tests / cmd+k integration). */
@@ -154,11 +158,7 @@ export function LabelPicker({
searchable
searchPlaceholder={t(($) => $.pickers.label.search_placeholder)}
onSearchChange={setFilter}
triggerRender={
hasLabels ? (
<div className="flex flex-wrap items-center gap-1 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors" />
) : undefined
}
triggerRender={hasLabels ? LABEL_TRIGGER_RENDER : undefined}
trigger={
hasLabels ? (
<>

View File

@@ -68,7 +68,7 @@ export function PropertyPicker({
const placeholder = searchPlaceholder ?? t(($) => $.filters.placeholder);
const filterAria = t(($) => $.pickers.filter_options_aria);
const [query, setQuery] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const highlightedIndexRef = useRef(-1);
const [tooltipHover, setTooltipHover] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
// Show the tooltip only while the trigger is hovered AND the popover is
@@ -83,23 +83,28 @@ export function PropertyPicker({
);
}, []);
// Apply/remove highlight class via DOM when index changes
useEffect(() => {
const applyHighlight = useCallback((index: number) => {
highlightedIndexRef.current = index;
const items = getItems();
for (const item of items) {
item.classList.remove(HIGHLIGHT_CLASS);
}
if (highlightedIndex >= 0 && highlightedIndex < items.length) {
items[highlightedIndex]?.classList.add(HIGHLIGHT_CLASS);
if (index >= 0 && index < items.length) {
items[index]?.classList.add(HIGHLIGHT_CLASS);
}
}, [highlightedIndex, getItems, children]); // re-run when children change (filtered list updates)
}, [getItems]);
// Re-apply highlight when children change (filtered list updates)
useEffect(() => {
applyHighlight(highlightedIndexRef.current);
}, [applyHighlight, children]);
const handleOpenChange = useCallback(
(v: boolean) => {
onOpenChange(v);
if (!v) {
setQuery("");
setHighlightedIndex(-1);
highlightedIndexRef.current = -1;
onSearchChange?.("");
}
},
@@ -116,29 +121,28 @@ export function PropertyPicker({
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlightedIndex((prev) => {
const next = prev < items.length - 1 ? prev + 1 : 0;
items[next]?.scrollIntoView({ block: "nearest" });
return next;
});
const prev = highlightedIndexRef.current;
const next = prev < items.length - 1 ? prev + 1 : 0;
applyHighlight(next);
items[next]?.scrollIntoView({ block: "nearest" });
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlightedIndex((prev) => {
const next = prev > 0 ? prev - 1 : items.length - 1;
items[next]?.scrollIntoView({ block: "nearest" });
return next;
});
const prev = highlightedIndexRef.current;
const next = prev > 0 ? prev - 1 : items.length - 1;
applyHighlight(next);
items[next]?.scrollIntoView({ block: "nearest" });
} else if (e.key === "Enter") {
e.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < items.length) {
items[highlightedIndex]?.click();
const idx = highlightedIndexRef.current;
if (idx >= 0 && idx < items.length) {
items[idx]?.click();
} else if (items.length === 1) {
// Auto-select when only one result
items[0]?.click();
}
}
},
[getItems, highlightedIndex],
[getItems, applyHighlight],
);
const popoverTrigger = (
@@ -168,7 +172,7 @@ export function PropertyPicker({
value={query}
onChange={(e) => {
setQuery(e.target.value);
setHighlightedIndex(0);
applyHighlight(0);
onSearchChange?.(e.target.value);
}}
onKeyDown={handleKeyDown}

View File

@@ -93,8 +93,7 @@ function ReplyInput({
if (!content || submitting) return;
// Only send attachment IDs for uploads still present in the content.
const activeIds = pendingAttachments
.filter((a) => content.includes(a.url))
.map((a) => a.id);
.flatMap((a) => content.includes(a.url) ? [a.id] : []);
setSubmitting(true);
try {
await onSubmit(content, activeIds.length > 0 ? activeIds : undefined);

View File

@@ -115,18 +115,27 @@ function parseCellId(id: string): { laneKey: string; status: string } | null {
};
}
function findCellIn(
type CellIndex = Map<string, { laneKey: string; status: string }>;
function buildCellIndex(
data: Record<string, Record<string, string[]>>,
): CellIndex {
const index: CellIndex = new Map();
for (const [pk, statusMap] of Object.entries(data)) {
for (const [status, ids] of Object.entries(statusMap)) {
for (const id of ids) index.set(id, { laneKey: pk, status });
}
}
return index;
}
function findCellIn(
cellIndex: CellIndex,
cellIds: Set<string>,
id: string,
): { laneKey: string; status: string } | null {
if (cellIds.has(id)) return parseCellId(id);
for (const [pk, statusMap] of Object.entries(data)) {
for (const [status, ids] of Object.entries(statusMap)) {
if (ids.includes(id)) return { laneKey: pk, status };
}
}
return null;
return cellIndex.get(id) ?? null;
}
function cellId(laneKey: string, status: IssueStatus): string {
@@ -210,6 +219,7 @@ interface LaneGroup {
const EMPTY_PROGRESS_MAP = new Map<string, ChildProgress>();
const EMPTY_PROJECTS: Project[] = [];
const EMPTY_STATUSES: IssueStatus[] = [];
/**
* Build parent-grouping lanes. The "No parent" lane is always pinned at the
@@ -431,7 +441,7 @@ export function SwimLaneView({
issues,
unfilteredIssues,
visibleStatuses = BOARD_STATUSES,
hiddenStatuses = [],
hiddenStatuses = EMPTY_STATUSES,
onMoveIssue,
childProgressMap = EMPTY_PROGRESS_MAP,
myIssuesScope,
@@ -694,8 +704,9 @@ export function SwimLaneView({
const overId = over.id as string;
setLocalCells((prev) => {
const activeCell = findCellIn(prev, cellSet, activeId);
const overCell = findCellIn(prev, cellSet, overId);
const idx = buildCellIndex(prev);
const activeCell = findCellIn(idx, cellSet, activeId);
const overCell = findCellIn(idx, cellSet, overId);
if (!activeCell || !overCell) return prev;
if (
activeCell.laneKey === overCell.laneKey &&
@@ -791,8 +802,7 @@ export function SwimLaneView({
) {
// Visible non-pinned lanes, in current render order.
const visibleOrder = laneGroups
.filter((g) => !g.isPinned && !g.isOrphan)
.map((g) => g.rawId);
.flatMap((g) => !g.isPinned && !g.isOrphan ? [g.rawId] : []);
const fromIdx = visibleOrder.indexOf(activeLaneRef.rawId);
const toIdx = visibleOrder.indexOf(overLaneRef.rawId);
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return;
@@ -819,9 +829,10 @@ export function SwimLaneView({
if (activeLaneRef || overLaneRef) return;
const cols = localCellsRef.current;
const colsIdx = buildCellIndex(cols);
const activeCell = findCellIn(cols, cellSet, activeId);
const overCell = findCellIn(cols, cellSet, overId);
const activeCell = findCellIn(colsIdx, cellSet, activeId);
const overCell = findCellIn(colsIdx, cellSet, overId);
if (!activeCell || !overCell) {
reset();
return;
@@ -863,7 +874,8 @@ export function SwimLaneView({
}
}
const finalOverCell = findCellIn(finalCells, cellSet, activeId);
const finalIdx = finalCells === cols ? colsIdx : buildCellIndex(finalCells);
const finalOverCell = findCellIn(finalIdx, cellSet, activeId);
if (!finalOverCell) {
reset();
return;
@@ -961,9 +973,7 @@ export function SwimLaneView({
are wrapped in a SortableContext so users can reorder lanes by
dragging the grip handle. */}
<div className="flex flex-col gap-4">
{laneGroups
.filter((g) => g.isPinned)
.map((lane) => (
{laneGroups.flatMap((lane) => !lane.isPinned ? [] : [(
<DraggableSwimLane
key={lane.key}
lane={lane}
@@ -978,16 +988,12 @@ export function SwimLaneView({
paths={paths}
projectId={projectId}
/>
))}
)])}
<SortableContext
items={laneGroups
.filter((g) => !g.isPinned)
.map((g) => laneIdFor(swimlaneGrouping, g.rawId))}
items={laneGroups.flatMap((g) => g.isPinned ? [] : [laneIdFor(swimlaneGrouping, g.rawId)])}
strategy={verticalListSortingStrategy}
>
{laneGroups
.filter((g) => !g.isPinned)
.map((lane) => (
{laneGroups.flatMap((lane) => lane.isPinned ? [] : [(
<DraggableSwimLane
key={lane.key}
lane={lane}
@@ -1002,7 +1008,7 @@ export function SwimLaneView({
paths={paths}
projectId={projectId}
/>
))}
)])}
</SortableContext>
{/* Per-status load-more sentinels — same bucketed cache as Board. */}
@@ -1096,7 +1102,7 @@ function DraggableSwimLane({
don't nest an <a> inside a <button>. The drag listeners attach
here so the whole header row is the drag surface. */}
<div
className="mb-2 flex w-full items-center gap-2 rounded-md px-1 py-1"
className="mb-2 flex w-full items-center gap-2 rounded-md p-1"
{...attributes}
{...listeners}
>

View File

@@ -37,7 +37,7 @@ export function assigneeGroupId(
return type && id ? `assignee:${type}:${id}` : UNASSIGNED_GROUP_ID;
}
export function getIssueGroupId(issue: Issue, grouping: IssueGrouping): string {
function getIssueGroupId(issue: Issue, grouping: IssueGrouping): string {
if (grouping === "status") return statusGroupId(issue.status);
return assigneeGroupId(issue.assignee_type, issue.assignee_id);
}
@@ -66,16 +66,25 @@ export function computePosition(ids: string[], activeId: string, issueMap: Map<s
return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2;
}
export function findColumn(
export type ColumnIndex = Map<string, string>;
export function buildColumnIndex(
columns: Record<string, string[]>,
): ColumnIndex {
const index: ColumnIndex = new Map();
for (const [columnId, ids] of Object.entries(columns)) {
for (const id of ids) index.set(id, columnId);
}
return index;
}
export function findColumn(
columnIndex: ColumnIndex,
id: string,
columnIds: Set<string>,
): string | null {
if (columnIds.has(id)) return id;
for (const [columnId, ids] of Object.entries(columns)) {
if (ids.includes(id)) return columnId;
}
return null;
return columnIndex.get(id) ?? null;
}
export function issueMatchesGroup(issue: Issue, group: BoardColumnGroup): boolean {

View File

@@ -50,7 +50,10 @@
"issue_count_other": "{{count}} issues",
"reset": "Reset all filters",
"active_count_one": "{{count}} filter",
"active_count_other": "{{count}} filters"
"active_count_other": "{{count}} filters",
"filter_actors_aria": "Filter people",
"filter_projects_aria": "Filter projects",
"filter_labels_aria": "Filter labels"
},
"display": {
"tooltip": "Display settings",

View File

@@ -48,7 +48,10 @@
"squads_group": "小队",
"issue_count_other": "{{count}} 个 issue",
"reset": "重置全部筛选",
"active_count_other": "{{count}} 个筛选"
"active_count_other": "{{count}} 个筛选",
"filter_actors_aria": "筛选人员",
"filter_projects_aria": "筛选项目",
"filter_labels_aria": "筛选标签"
},
"display": {
"tooltip": "显示设置",