Add archive project button and filter archived projects from selectors

- Add "Archive project" / "Unarchive project" button in the project
  configuration danger zone (ProjectProperties)
- Filter archived projects from the Projects listing page
- Filter archived projects from NewIssueDialog project selector
- Filter archived projects from IssueProperties project picker
  (keeps current project visible even if archived)
- Filter archived projects from CommandPalette
- SidebarProjects already filters archived projects

Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dotta
2026-03-14 17:47:53 -05:00
parent c94132bc7e
commit aea133ff9f
6 changed files with 83 additions and 9 deletions

View File

@@ -75,11 +75,15 @@ export function CommandPalette() {
enabled: !!selectedCompanyId && open, enabled: !!selectedCompanyId && open,
}); });
const { data: projects = [] } = useQuery({ const { data: allProjects = [] } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!), queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!), queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && open, enabled: !!selectedCompanyId && open,
}); });
const projects = useMemo(
() => allProjects.filter((p) => !p.archivedAt),
[allProjects],
);
function go(path: string) { function go(path: string) {
setOpen(false); setOpen(false);

View File

@@ -131,8 +131,12 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp
queryFn: () => projectsApi.list(companyId!), queryFn: () => projectsApi.list(companyId!),
enabled: !!companyId, enabled: !!companyId,
}); });
const activeProjects = useMemo(
() => (projects ?? []).filter((p) => !p.archivedAt || p.id === issue.projectId),
[projects, issue.projectId],
);
const { orderedProjects } = useProjectOrder({ const { orderedProjects } = useProjectOrder({
projects: projects ?? [], projects: activeProjects,
companyId, companyId,
userId: currentUserId, userId: currentUserId,
}); });

View File

@@ -288,8 +288,12 @@ export function NewIssueDialog() {
queryFn: () => authApi.getSession(), queryFn: () => authApi.getSession(),
}); });
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null; const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
const activeProjects = useMemo(
() => (projects ?? []).filter((p) => !p.archivedAt),
[projects],
);
const { orderedProjects } = useProjectOrder({ const { orderedProjects } = useProjectOrder({
projects: projects ?? [], projects: activeProjects,
companyId: effectiveCompanyId, companyId: effectiveCompanyId,
userId: currentUserId, userId: currentUserId,
}); });

View File

@@ -13,7 +13,7 @@ import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { AlertCircle, Check, ExternalLink, Github, Loader2, Plus, Trash2, X } from "lucide-react"; import { AlertCircle, Archive, ArchiveRestore, Check, ExternalLink, Github, Loader2, Plus, Trash2, X } from "lucide-react";
import { ChoosePathButton } from "./PathInstructionsModal"; import { ChoosePathButton } from "./PathInstructionsModal";
import { DraftInput } from "./agent-config-primitives"; import { DraftInput } from "./agent-config-primitives";
import { InlineEditor } from "./InlineEditor"; import { InlineEditor } from "./InlineEditor";
@@ -34,6 +34,8 @@ interface ProjectPropertiesProps {
onUpdate?: (data: Record<string, unknown>) => void; onUpdate?: (data: Record<string, unknown>) => void;
onFieldUpdate?: (field: ProjectConfigFieldKey, data: Record<string, unknown>) => void; onFieldUpdate?: (field: ProjectConfigFieldKey, data: Record<string, unknown>) => void;
getFieldSaveState?: (field: ProjectConfigFieldKey) => ProjectFieldSaveState; getFieldSaveState?: (field: ProjectConfigFieldKey) => ProjectFieldSaveState;
onArchive?: (archived: boolean) => void;
archivePending?: boolean;
} }
export type ProjectFieldSaveState = "idle" | "saving" | "saved" | "error"; export type ProjectFieldSaveState = "idle" | "saving" | "saved" | "error";
@@ -152,7 +154,7 @@ function ProjectStatusPicker({ status, onChange }: { status: string; onChange: (
); );
} }
export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState }: ProjectPropertiesProps) { export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState, onArchive, archivePending }: ProjectPropertiesProps) {
const { selectedCompanyId } = useCompany(); const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [goalOpen, setGoalOpen] = useState(false); const [goalOpen, setGoalOpen] = useState(false);
@@ -954,6 +956,45 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
)} )}
</div> </div>
{onArchive && (
<>
<Separator className="my-4" />
<div className="space-y-4 py-4">
<div className="text-xs font-medium text-destructive uppercase tracking-wide">
Danger Zone
</div>
<div className="space-y-3 rounded-md border border-destructive/40 bg-destructive/5 px-4 py-4">
<p className="text-sm text-muted-foreground">
{project.archivedAt
? "Unarchive this project to restore it in the sidebar and project selectors."
: "Archive this project to hide it from the sidebar and project selectors."}
</p>
<Button
size="sm"
variant="destructive"
disabled={archivePending}
onClick={() => {
const action = project.archivedAt ? "Unarchive" : "Archive";
const confirmed = window.confirm(
`${action} project "${project.name}"?`,
);
if (!confirmed) return;
onArchive(!project.archivedAt);
}}
>
{archivePending ? (
<><Loader2 className="h-3 w-3 animate-spin mr-1" />{project.archivedAt ? "Unarchiving..." : "Archiving..."}</>
) : project.archivedAt ? (
<><ArchiveRestore className="h-3 w-3 mr-1" />Unarchive project</>
) : (
<><Archive className="h-3 w-3 mr-1" />Archive project</>
)}
</Button>
</div>
</div>
</>
)}
</div> </div>
); );
} }

View File

@@ -274,6 +274,21 @@ export function ProjectDetail() {
onSuccess: invalidateProject, onSuccess: invalidateProject,
}); });
const archiveProject = useMutation({
mutationFn: (archived: boolean) =>
projectsApi.update(
projectLookupRef,
{ archivedAt: archived ? new Date().toISOString() : null },
resolvedCompanyId ?? lookupCompanyId,
),
onSuccess: (_, archived) => {
invalidateProject();
if (archived) {
navigate("/projects");
}
},
});
const uploadImage = useMutation({ const uploadImage = useMutation({
mutationFn: async (file: File) => { mutationFn: async (file: File) => {
if (!resolvedCompanyId) throw new Error("No company selected"); if (!resolvedCompanyId) throw new Error("No company selected");
@@ -476,6 +491,8 @@ export function ProjectDetail() {
onUpdate={(data) => updateProject.mutate(data)} onUpdate={(data) => updateProject.mutate(data)}
onFieldUpdate={updateProjectField} onFieldUpdate={updateProjectField}
getFieldSaveState={(field) => fieldSaveStates[field] ?? "idle"} getFieldSaveState={(field) => fieldSaveStates[field] ?? "idle"}
onArchive={(archived) => archiveProject.mutate(archived)}
archivePending={archiveProject.isPending}
/> />
</div> </div>
)} )}

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { projectsApi } from "../api/projects"; import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext"; import { useCompany } from "../context/CompanyContext";
@@ -22,11 +22,15 @@ export function Projects() {
setBreadcrumbs([{ label: "Projects" }]); setBreadcrumbs([{ label: "Projects" }]);
}, [setBreadcrumbs]); }, [setBreadcrumbs]);
const { data: projects, isLoading, error } = useQuery({ const { data: allProjects, isLoading, error } = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId!), queryKey: queryKeys.projects.list(selectedCompanyId!),
queryFn: () => projectsApi.list(selectedCompanyId!), queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId, enabled: !!selectedCompanyId,
}); });
const projects = useMemo(
() => (allProjects ?? []).filter((p) => !p.archivedAt),
[allProjects],
);
if (!selectedCompanyId) { if (!selectedCompanyId) {
return <EmptyState icon={Hexagon} message="Select a company to view projects." />; return <EmptyState icon={Hexagon} message="Select a company to view projects." />;
@@ -47,7 +51,7 @@ export function Projects() {
{error && <p className="text-sm text-destructive">{error.message}</p>} {error && <p className="text-sm text-destructive">{error.message}</p>}
{projects && projects.length === 0 && ( {!isLoading && projects.length === 0 && (
<EmptyState <EmptyState
icon={Hexagon} icon={Hexagon}
message="No projects yet." message="No projects yet."
@@ -56,7 +60,7 @@ export function Projects() {
/> />
)} )}
{projects && projects.length > 0 && ( {projects.length > 0 && (
<div className="border border-border"> <div className="border border-border">
{projects.map((project) => ( {projects.map((project) => (
<EntityRow <EntityRow