feat(ui): drag-to-reorder sidebar projects with persistent order
Add drag-and-drop reordering to sidebar project list using dnd-kit, persisted per-user via localStorage. Use consistent project order in issue properties, new issue dialog, and issue detail mention options. Move projects section below Work section in sidebar. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import { issuesApi } from "../api/issues";
|
|||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||||
import { StatusIcon } from "./StatusIcon";
|
import { StatusIcon } from "./StatusIcon";
|
||||||
import { PriorityIcon } from "./PriorityIcon";
|
import { PriorityIcon } from "./PriorityIcon";
|
||||||
import { Identity } from "./Identity";
|
import { Identity } from "./Identity";
|
||||||
@@ -125,6 +126,11 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||||||
queryFn: () => projectsApi.list(companyId!),
|
queryFn: () => projectsApi.list(companyId!),
|
||||||
enabled: !!companyId,
|
enabled: !!companyId,
|
||||||
});
|
});
|
||||||
|
const { orderedProjects } = useProjectOrder({
|
||||||
|
projects: projects ?? [],
|
||||||
|
companyId,
|
||||||
|
userId: currentUserId,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: labels } = useQuery({
|
const { data: labels } = useQuery({
|
||||||
queryKey: queryKeys.issues.labels(companyId!),
|
queryKey: queryKeys.issues.labels(companyId!),
|
||||||
@@ -165,8 +171,8 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||||||
};
|
};
|
||||||
|
|
||||||
const projectName = (id: string | null) => {
|
const projectName = (id: string | null) => {
|
||||||
if (!id || !projects) return id?.slice(0, 8) ?? "None";
|
if (!id) return id?.slice(0, 8) ?? "None";
|
||||||
const project = projects.find((p) => p.id === id);
|
const project = orderedProjects.find((p) => p.id === id);
|
||||||
return project?.name ?? id.slice(0, 8);
|
return project?.name ?? id.slice(0, 8);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -359,7 +365,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||||||
<>
|
<>
|
||||||
<span
|
<span
|
||||||
className="shrink-0 h-3 w-3 rounded-sm"
|
className="shrink-0 h-3 w-3 rounded-sm"
|
||||||
style={{ backgroundColor: projects?.find((p) => p.id === issue.projectId)?.color ?? "#6366f1" }}
|
style={{ backgroundColor: orderedProjects.find((p) => p.id === issue.projectId)?.color ?? "#6366f1" }}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm truncate">{projectName(issue.projectId)}</span>
|
<span className="text-sm truncate">{projectName(issue.projectId)}</span>
|
||||||
</>
|
</>
|
||||||
@@ -389,7 +395,7 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
|
|||||||
>
|
>
|
||||||
No project
|
No project
|
||||||
</button>
|
</button>
|
||||||
{(projects ?? [])
|
{orderedProjects
|
||||||
.filter((p) => {
|
.filter((p) => {
|
||||||
if (!projectSearch.trim()) return true;
|
if (!projectSearch.trim()) return true;
|
||||||
const q = projectSearch.toLowerCase();
|
const q = projectSearch.toLowerCase();
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { useToast } from "../context/ToastContext";
|
|||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
|
import { authApi } from "../api/auth";
|
||||||
import { assetsApi } from "../api/assets";
|
import { assetsApi } from "../api/assets";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -195,6 +197,16 @@ export function NewIssueDialog() {
|
|||||||
queryFn: () => projectsApi.list(effectiveCompanyId!),
|
queryFn: () => projectsApi.list(effectiveCompanyId!),
|
||||||
enabled: !!effectiveCompanyId && newIssueOpen,
|
enabled: !!effectiveCompanyId && newIssueOpen,
|
||||||
});
|
});
|
||||||
|
const { data: session } = useQuery({
|
||||||
|
queryKey: queryKeys.auth.session,
|
||||||
|
queryFn: () => authApi.getSession(),
|
||||||
|
});
|
||||||
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||||
|
const { orderedProjects } = useProjectOrder({
|
||||||
|
projects: projects ?? [],
|
||||||
|
companyId: effectiveCompanyId,
|
||||||
|
userId: currentUserId,
|
||||||
|
});
|
||||||
|
|
||||||
const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === assigneeId)?.adapterType ?? null;
|
const assigneeAdapterType = (agents ?? []).find((agent) => agent.id === assigneeId)?.adapterType ?? null;
|
||||||
const supportsAssigneeOverrides = Boolean(
|
const supportsAssigneeOverrides = Boolean(
|
||||||
@@ -212,8 +224,7 @@ export function NewIssueDialog() {
|
|||||||
kind: "agent",
|
kind: "agent",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const sortedProjects = [...(projects ?? [])].sort((a, b) => a.name.localeCompare(b.name));
|
for (const project of orderedProjects) {
|
||||||
for (const project of sortedProjects) {
|
|
||||||
options.push({
|
options.push({
|
||||||
id: `project:${project.id}`,
|
id: `project:${project.id}`,
|
||||||
name: project.name,
|
name: project.name,
|
||||||
@@ -223,7 +234,7 @@ export function NewIssueDialog() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return options;
|
return options;
|
||||||
}, [agents, projects]);
|
}, [agents, orderedProjects]);
|
||||||
|
|
||||||
const { data: assigneeAdapterModels } = useQuery({
|
const { data: assigneeAdapterModels } = useQuery({
|
||||||
queryKey: ["adapter-models", assigneeAdapterType],
|
queryKey: ["adapter-models", assigneeAdapterType],
|
||||||
@@ -434,7 +445,7 @@ export function NewIssueDialog() {
|
|||||||
const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!;
|
const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!;
|
||||||
const currentPriority = priorities.find((p) => p.value === priority);
|
const currentPriority = priorities.find((p) => p.value === priority);
|
||||||
const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId);
|
const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId);
|
||||||
const currentProject = (projects ?? []).find((p) => p.id === projectId);
|
const currentProject = orderedProjects.find((project) => project.id === projectId);
|
||||||
const assigneeOptionsTitle =
|
const assigneeOptionsTitle =
|
||||||
assigneeAdapterType === "claude_local"
|
assigneeAdapterType === "claude_local"
|
||||||
? "Claude options"
|
? "Claude options"
|
||||||
@@ -458,12 +469,12 @@ export function NewIssueDialog() {
|
|||||||
);
|
);
|
||||||
const projectOptions = useMemo<InlineEntityOption[]>(
|
const projectOptions = useMemo<InlineEntityOption[]>(
|
||||||
() =>
|
() =>
|
||||||
(projects ?? []).map((project) => ({
|
orderedProjects.map((project) => ({
|
||||||
id: project.id,
|
id: project.id,
|
||||||
label: project.name,
|
label: project.name,
|
||||||
searchText: project.description ?? "",
|
searchText: project.description ?? "",
|
||||||
})),
|
})),
|
||||||
[projects],
|
[orderedProjects],
|
||||||
);
|
);
|
||||||
const modelOverrideOptions = useMemo<InlineEntityOption[]>(
|
const modelOverrideOptions = useMemo<InlineEntityOption[]>(
|
||||||
() =>
|
() =>
|
||||||
@@ -663,7 +674,7 @@ export function NewIssueDialog() {
|
|||||||
}
|
}
|
||||||
renderOption={(option) => {
|
renderOption={(option) => {
|
||||||
if (!option.id) return <span className="truncate">{option.label}</span>;
|
if (!option.id) return <span className="truncate">{option.label}</span>;
|
||||||
const project = (projects ?? []).find((item) => item.id === option.id);
|
const project = orderedProjects.find((item) => item.id === option.id);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -88,13 +88,13 @@ export function Sidebar() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SidebarProjects />
|
|
||||||
|
|
||||||
<SidebarSection label="Work">
|
<SidebarSection label="Work">
|
||||||
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
||||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
|
|
||||||
|
<SidebarProjects />
|
||||||
|
|
||||||
<SidebarAgents />
|
<SidebarAgents />
|
||||||
|
|
||||||
<SidebarSection label="Company">
|
<SidebarSection label="Company">
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
import { useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { NavLink, useLocation } from "react-router-dom";
|
import { NavLink, useLocation } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ChevronRight, Plus } from "lucide-react";
|
import { ChevronRight, Plus } from "lucide-react";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
PointerSensor,
|
||||||
|
closestCenter,
|
||||||
|
type DragEndEvent,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useSidebar } from "../context/SidebarContext";
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
|
import { authApi } from "../api/auth";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
@@ -15,6 +27,60 @@ import {
|
|||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
import type { Project } from "@paperclip/shared";
|
import type { Project } from "@paperclip/shared";
|
||||||
|
|
||||||
|
function SortableProjectItem({
|
||||||
|
activeProjectId,
|
||||||
|
isMobile,
|
||||||
|
project,
|
||||||
|
setSidebarOpen,
|
||||||
|
}: {
|
||||||
|
activeProjectId: string | null;
|
||||||
|
isMobile: boolean;
|
||||||
|
project: Project;
|
||||||
|
setSidebarOpen: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: project.id });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
zIndex: isDragging ? 10 : undefined,
|
||||||
|
}}
|
||||||
|
className={cn(isDragging && "opacity-80")}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<NavLink
|
||||||
|
to={`/projects/${project.id}/issues`}
|
||||||
|
onClick={() => {
|
||||||
|
if (isMobile) setSidebarOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
|
||||||
|
activeProjectId === project.id
|
||||||
|
? "bg-accent text-foreground"
|
||||||
|
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="shrink-0 h-3.5 w-3.5 rounded-sm"
|
||||||
|
style={{ backgroundColor: project.color ?? "#6366f1" }}
|
||||||
|
/>
|
||||||
|
<span className="flex-1 truncate">{project.name}</span>
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SidebarProjects() {
|
export function SidebarProjects() {
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
@@ -27,15 +93,45 @@ export function SidebarProjects() {
|
|||||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
const { data: session } = useQuery({
|
||||||
|
queryKey: queryKeys.auth.session,
|
||||||
|
queryFn: () => authApi.getSession(),
|
||||||
|
});
|
||||||
|
|
||||||
// Filter out archived projects
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||||
const visibleProjects = (projects ?? []).filter(
|
|
||||||
(p: Project) => !p.archivedAt
|
const visibleProjects = useMemo(
|
||||||
|
() => (projects ?? []).filter((project: Project) => !project.archivedAt),
|
||||||
|
[projects],
|
||||||
);
|
);
|
||||||
|
const { orderedProjects, persistOrder } = useProjectOrder({
|
||||||
|
projects: visibleProjects,
|
||||||
|
companyId: selectedCompanyId,
|
||||||
|
userId: currentUserId,
|
||||||
|
});
|
||||||
|
|
||||||
// Extract current projectId from URL
|
|
||||||
const projectMatch = location.pathname.match(/^\/projects\/([^/]+)/);
|
const projectMatch = location.pathname.match(/^\/projects\/([^/]+)/);
|
||||||
const activeProjectId = projectMatch?.[1] ?? null;
|
const activeProjectId = projectMatch?.[1] ?? null;
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: { distance: 8 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
|
const ids = orderedProjects.map((project) => project.id);
|
||||||
|
const oldIndex = ids.indexOf(active.id as string);
|
||||||
|
const newIndex = ids.indexOf(over.id as string);
|
||||||
|
if (oldIndex === -1 || newIndex === -1) return;
|
||||||
|
|
||||||
|
persistOrder(arrayMove(ids, oldIndex, newIndex));
|
||||||
|
},
|
||||||
|
[orderedProjects, persistOrder],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible open={open} onOpenChange={setOpen}>
|
<Collapsible open={open} onOpenChange={setOpen}>
|
||||||
@@ -66,31 +162,28 @@ export function SidebarProjects() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="flex flex-col gap-0.5 mt-0.5">
|
<DndContext
|
||||||
{visibleProjects.map((project: Project) => (
|
sensors={sensors}
|
||||||
<NavLink
|
collisionDetection={closestCenter}
|
||||||
key={project.id}
|
onDragEnd={handleDragEnd}
|
||||||
to={`/projects/${project.id}/issues`}
|
>
|
||||||
onClick={() => {
|
<SortableContext
|
||||||
if (isMobile) setSidebarOpen(false);
|
items={orderedProjects.map((project) => project.id)}
|
||||||
}}
|
strategy={verticalListSortingStrategy}
|
||||||
className={cn(
|
>
|
||||||
"flex items-center gap-2.5 px-3 py-1.5 text-[13px] font-medium transition-colors",
|
<div className="flex flex-col gap-0.5 mt-0.5">
|
||||||
activeProjectId === project.id
|
{orderedProjects.map((project: Project) => (
|
||||||
? "bg-accent text-foreground"
|
<SortableProjectItem
|
||||||
: "text-foreground/80 hover:bg-accent/50 hover:text-foreground"
|
key={project.id}
|
||||||
)}
|
activeProjectId={activeProjectId}
|
||||||
>
|
isMobile={isMobile}
|
||||||
<span
|
project={project}
|
||||||
className="shrink-0 h-3.5 w-3.5 rounded-sm"
|
setSidebarOpen={setSidebarOpen}
|
||||||
style={{
|
/>
|
||||||
backgroundColor: project.color ?? "#6366f1",
|
))}
|
||||||
}}
|
</div>
|
||||||
/>
|
</SortableContext>
|
||||||
<span className="flex-1 truncate">{project.name}</span>
|
</DndContext>
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
);
|
);
|
||||||
|
|||||||
105
ui/src/hooks/useProjectOrder.ts
Normal file
105
ui/src/hooks/useProjectOrder.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import type { Project } from "@paperclip/shared";
|
||||||
|
import {
|
||||||
|
getProjectOrderStorageKey,
|
||||||
|
PROJECT_ORDER_UPDATED_EVENT,
|
||||||
|
readProjectOrder,
|
||||||
|
sortProjectsByStoredOrder,
|
||||||
|
writeProjectOrder,
|
||||||
|
} from "../lib/project-order";
|
||||||
|
|
||||||
|
type UseProjectOrderParams = {
|
||||||
|
projects: Project[];
|
||||||
|
companyId: string | null | undefined;
|
||||||
|
userId: string | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectOrderUpdatedDetail = {
|
||||||
|
storageKey: string;
|
||||||
|
orderedIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function areEqual(a: string[], b: string[]) {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
for (let i = 0; i < a.length; i += 1) {
|
||||||
|
if (a[i] !== b[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOrderIds(projects: Project[], orderedIds: string[]) {
|
||||||
|
return sortProjectsByStoredOrder(projects, orderedIds).map((project) => project.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProjectOrder({ projects, companyId, userId }: UseProjectOrderParams) {
|
||||||
|
const storageKey = useMemo(() => {
|
||||||
|
if (!companyId) return null;
|
||||||
|
return getProjectOrderStorageKey(companyId, userId);
|
||||||
|
}, [companyId, userId]);
|
||||||
|
|
||||||
|
const [orderedIds, setOrderedIds] = useState<string[]>(() => {
|
||||||
|
if (!storageKey) return projects.map((project) => project.id);
|
||||||
|
return buildOrderIds(projects, readProjectOrder(storageKey));
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextIds = storageKey
|
||||||
|
? buildOrderIds(projects, readProjectOrder(storageKey))
|
||||||
|
: projects.map((project) => project.id);
|
||||||
|
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
|
||||||
|
}, [projects, storageKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!storageKey) return;
|
||||||
|
|
||||||
|
const syncFromIds = (ids: string[]) => {
|
||||||
|
const nextIds = buildOrderIds(projects, ids);
|
||||||
|
setOrderedIds((current) => (areEqual(current, nextIds) ? current : nextIds));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onStorage = (event: StorageEvent) => {
|
||||||
|
if (event.key !== storageKey) return;
|
||||||
|
syncFromIds(readProjectOrder(storageKey));
|
||||||
|
};
|
||||||
|
const onCustomEvent = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<ProjectOrderUpdatedDetail>).detail;
|
||||||
|
if (!detail || detail.storageKey !== storageKey) return;
|
||||||
|
syncFromIds(detail.orderedIds);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("storage", onStorage);
|
||||||
|
window.addEventListener(PROJECT_ORDER_UPDATED_EVENT, onCustomEvent);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("storage", onStorage);
|
||||||
|
window.removeEventListener(PROJECT_ORDER_UPDATED_EVENT, onCustomEvent);
|
||||||
|
};
|
||||||
|
}, [projects, storageKey]);
|
||||||
|
|
||||||
|
const orderedProjects = useMemo(
|
||||||
|
() => sortProjectsByStoredOrder(projects, orderedIds),
|
||||||
|
[projects, orderedIds],
|
||||||
|
);
|
||||||
|
|
||||||
|
const persistOrder = useCallback(
|
||||||
|
(ids: string[]) => {
|
||||||
|
const idSet = new Set(projects.map((project) => project.id));
|
||||||
|
const filtered = ids.filter((id) => idSet.has(id));
|
||||||
|
for (const project of projects) {
|
||||||
|
if (!filtered.includes(project.id)) filtered.push(project.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
setOrderedIds((current) => (areEqual(current, filtered) ? current : filtered));
|
||||||
|
if (storageKey) {
|
||||||
|
writeProjectOrder(storageKey, filtered);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projects, storageKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
orderedProjects,
|
||||||
|
orderedIds,
|
||||||
|
persistOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
70
ui/src/lib/project-order.ts
Normal file
70
ui/src/lib/project-order.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { Project } from "@paperclip/shared";
|
||||||
|
|
||||||
|
export const PROJECT_ORDER_UPDATED_EVENT = "paperclip:project-order-updated";
|
||||||
|
const PROJECT_ORDER_STORAGE_PREFIX = "paperclip.projectOrder";
|
||||||
|
const ANONYMOUS_USER_ID = "anonymous";
|
||||||
|
|
||||||
|
type ProjectOrderUpdatedDetail = {
|
||||||
|
storageKey: string;
|
||||||
|
orderedIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeIdList(value: unknown): string[] {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value.filter((item): item is string => typeof item === "string" && item.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUserId(userId: string | null | undefined): string {
|
||||||
|
if (!userId) return ANONYMOUS_USER_ID;
|
||||||
|
const trimmed = userId.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : ANONYMOUS_USER_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectOrderStorageKey(companyId: string, userId: string | null | undefined): string {
|
||||||
|
return `${PROJECT_ORDER_STORAGE_PREFIX}:${companyId}:${resolveUserId(userId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readProjectOrder(storageKey: string): string[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(storageKey);
|
||||||
|
if (!raw) return [];
|
||||||
|
return normalizeIdList(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeProjectOrder(storageKey: string, orderedIds: string[]) {
|
||||||
|
const normalized = normalizeIdList(orderedIds);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(normalized));
|
||||||
|
} catch {
|
||||||
|
// Ignore storage write failures in restricted browser contexts.
|
||||||
|
}
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent<ProjectOrderUpdatedDetail>(PROJECT_ORDER_UPDATED_EVENT, {
|
||||||
|
detail: { storageKey, orderedIds: normalized },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sortProjectsByStoredOrder(projects: Project[], orderedIds: string[]): Project[] {
|
||||||
|
if (projects.length === 0) return [];
|
||||||
|
if (orderedIds.length === 0) return projects;
|
||||||
|
|
||||||
|
const byId = new Map(projects.map((project) => [project.id, project]));
|
||||||
|
const sorted: Project[] = [];
|
||||||
|
|
||||||
|
for (const id of orderedIds) {
|
||||||
|
const project = byId.get(id);
|
||||||
|
if (!project) continue;
|
||||||
|
sorted.push(project);
|
||||||
|
byId.delete(id);
|
||||||
|
}
|
||||||
|
for (const project of byId.values()) {
|
||||||
|
sorted.push(project);
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { useToast } from "../context/ToastContext";
|
|||||||
import { usePanel } from "../context/PanelContext";
|
import { usePanel } from "../context/PanelContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { useProjectOrder } from "../hooks/useProjectOrder";
|
||||||
import { relativeTime, cn, formatTokens } from "../lib/utils";
|
import { relativeTime, cn, formatTokens } from "../lib/utils";
|
||||||
import { InlineEditor } from "../components/InlineEditor";
|
import { InlineEditor } from "../components/InlineEditor";
|
||||||
import { CommentThread } from "../components/CommentThread";
|
import { CommentThread } from "../components/CommentThread";
|
||||||
@@ -228,6 +229,12 @@ export function IssueDetail() {
|
|||||||
queryFn: () => projectsApi.list(selectedCompanyId!),
|
queryFn: () => projectsApi.list(selectedCompanyId!),
|
||||||
enabled: !!selectedCompanyId,
|
enabled: !!selectedCompanyId,
|
||||||
});
|
});
|
||||||
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||||
|
const { orderedProjects } = useProjectOrder({
|
||||||
|
projects: projects ?? [],
|
||||||
|
companyId: selectedCompanyId,
|
||||||
|
userId: currentUserId,
|
||||||
|
});
|
||||||
|
|
||||||
const agentMap = useMemo(() => {
|
const agentMap = useMemo(() => {
|
||||||
const map = new Map<string, Agent>();
|
const map = new Map<string, Agent>();
|
||||||
@@ -247,8 +254,7 @@ export function IssueDetail() {
|
|||||||
kind: "agent",
|
kind: "agent",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const sortedProjects = [...(projects ?? [])].sort((a, b) => a.name.localeCompare(b.name));
|
for (const project of orderedProjects) {
|
||||||
for (const project of sortedProjects) {
|
|
||||||
options.push({
|
options.push({
|
||||||
id: `project:${project.id}`,
|
id: `project:${project.id}`,
|
||||||
name: project.name,
|
name: project.name,
|
||||||
@@ -258,7 +264,7 @@ export function IssueDetail() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return options;
|
return options;
|
||||||
}, [agents, projects]);
|
}, [agents, orderedProjects]);
|
||||||
|
|
||||||
const childIssues = useMemo(() => {
|
const childIssues = useMemo(() => {
|
||||||
if (!allIssues || !issue) return [];
|
if (!allIssues || !issue) return [];
|
||||||
@@ -267,8 +273,6 @@ export function IssueDetail() {
|
|||||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||||
}, [allIssues, issue]);
|
}, [allIssues, issue]);
|
||||||
|
|
||||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
|
||||||
|
|
||||||
const canReassignFromComment = Boolean(
|
const canReassignFromComment = Boolean(
|
||||||
issue?.assigneeUserId &&
|
issue?.assigneeUserId &&
|
||||||
(issue.assigneeUserId === "local-board" || (currentUserId && issue.assigneeUserId === currentUserId)),
|
(issue.assigneeUserId === "local-board" || (currentUserId && issue.assigneeUserId === currentUserId)),
|
||||||
|
|||||||
Reference in New Issue
Block a user