mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-26 17:09:14 +02:00
Compare commits
1 Commits
agent/lamb
...
chore/issu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f72891ff20 |
@@ -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). */
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(); }}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ${
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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"
|
||||
}`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "显示设置",
|
||||
|
||||
Reference in New Issue
Block a user