mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
Merge pull request #990 from multica-ai/NevilleQingNY/fix-cmdk-stale-status
fix(views): resolve stale status in cmd+k recent issues list
This commit is contained in:
@@ -2,7 +2,6 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import type { IssueStatus } from "../../types";
|
||||
import {
|
||||
createWorkspaceAwareStorage,
|
||||
registerForWorkspaceRehydration,
|
||||
@@ -13,25 +12,22 @@ const MAX_RECENT_ISSUES = 20;
|
||||
|
||||
export interface RecentIssueEntry {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
status: IssueStatus;
|
||||
visitedAt: number;
|
||||
}
|
||||
|
||||
interface RecentIssuesState {
|
||||
items: RecentIssueEntry[];
|
||||
recordVisit: (entry: Omit<RecentIssueEntry, "visitedAt">) => void;
|
||||
recordVisit: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useRecentIssuesStore = create<RecentIssuesState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
items: [],
|
||||
recordVisit: (entry) =>
|
||||
recordVisit: (id) =>
|
||||
set((state) => {
|
||||
const filtered = state.items.filter((i) => i.id !== entry.id);
|
||||
const updated: RecentIssueEntry = { ...entry, visitedAt: Date.now() };
|
||||
const filtered = state.items.filter((i) => i.id !== id);
|
||||
const updated: RecentIssueEntry = { id, visitedAt: Date.now() };
|
||||
return {
|
||||
items: [updated, ...filtered].slice(0, MAX_RECENT_ISSUES),
|
||||
};
|
||||
|
||||
@@ -367,12 +367,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
|
||||
const recordVisit = useRecentIssuesStore((s) => s.recordVisit);
|
||||
useEffect(() => {
|
||||
if (issue) {
|
||||
recordVisit({
|
||||
id: issue.id,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
status: issue.status,
|
||||
});
|
||||
recordVisit(issue.id);
|
||||
}
|
||||
}, [issue?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
|
||||
@@ -5,10 +5,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SearchCommand } from "./search-command";
|
||||
import { useSearchStore } from "./search-store";
|
||||
|
||||
const { mockPush, mockSearchIssues, mockSearchProjects } = vi.hoisted(() => ({
|
||||
const { mockPush, mockSearchIssues, mockSearchProjects, mockRecentItems, mockAllIssues } = vi.hoisted(() => ({
|
||||
mockPush: vi.fn(),
|
||||
mockSearchIssues: vi.fn(),
|
||||
mockSearchProjects: vi.fn(),
|
||||
mockRecentItems: { current: [] as Array<{ id: string; visitedAt: number }> },
|
||||
mockAllIssues: { current: [] as Array<Record<string, unknown>> },
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
@@ -19,12 +21,24 @@ vi.mock("@multica/core/api", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/issues/stores", () => ({
|
||||
useRecentIssuesStore: (selector?: (state: { items: [] }) => unknown) => {
|
||||
const state = { items: [] as [] };
|
||||
useRecentIssuesStore: (selector?: (state: { items: typeof mockRecentItems.current }) => unknown) => {
|
||||
const state = { items: mockRecentItems.current };
|
||||
return selector ? selector(state) : state;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core", () => ({
|
||||
useWorkspaceId: () => "ws-test",
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/issues/queries", () => ({
|
||||
issueListOptions: () => ({ queryKey: ["issues", "ws-test", "list"], enabled: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: mockAllIssues.current }),
|
||||
}));
|
||||
|
||||
vi.mock("../navigation", () => ({
|
||||
useNavigation: () => ({
|
||||
push: mockPush,
|
||||
@@ -36,6 +50,8 @@ describe("SearchCommand", () => {
|
||||
mockPush.mockReset();
|
||||
mockSearchIssues.mockReset().mockResolvedValue({ issues: [] });
|
||||
mockSearchProjects.mockReset().mockResolvedValue({ projects: [] });
|
||||
mockRecentItems.current = [];
|
||||
mockAllIssues.current = [];
|
||||
|
||||
// cmdk calls scrollIntoView on the first selected item, which jsdom doesn't implement
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
@@ -97,4 +113,39 @@ describe("SearchCommand", () => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/settings");
|
||||
expect(useSearchStore.getState().open).toBe(false);
|
||||
});
|
||||
|
||||
it("renders recent issues from query cache joined with store visit records", () => {
|
||||
mockRecentItems.current = [
|
||||
{ id: "issue-1", visitedAt: 1000 },
|
||||
{ id: "issue-2", visitedAt: 900 },
|
||||
];
|
||||
mockAllIssues.current = [
|
||||
{ id: "issue-1", identifier: "MUL-1", title: "First issue", status: "todo" },
|
||||
{ id: "issue-2", identifier: "MUL-2", title: "Second issue", status: "done" },
|
||||
];
|
||||
|
||||
render(<SearchCommand />);
|
||||
|
||||
expect(screen.getByText("Recent")).toBeInTheDocument();
|
||||
expect(screen.getByText("First issue")).toBeInTheDocument();
|
||||
expect(screen.getByText("MUL-1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Second issue")).toBeInTheDocument();
|
||||
expect(screen.getByText("MUL-2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters out recent items not present in query cache", () => {
|
||||
mockRecentItems.current = [
|
||||
{ id: "issue-1", visitedAt: 1000 },
|
||||
{ id: "deleted-issue", visitedAt: 900 },
|
||||
];
|
||||
mockAllIssues.current = [
|
||||
{ id: "issue-1", identifier: "MUL-1", title: "Existing issue", status: "in_progress" },
|
||||
];
|
||||
|
||||
render(<SearchCommand />);
|
||||
|
||||
expect(screen.getByText("Recent")).toBeInTheDocument();
|
||||
expect(screen.getByText("Existing issue")).toBeInTheDocument();
|
||||
expect(screen.queryByText("deleted-issue")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,9 +17,12 @@ import {
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { SearchIssueResult, SearchProjectResult } from "@multica/core/types";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useRecentIssuesStore } from "@multica/core/issues/stores";
|
||||
import { issueListOptions } from "@multica/core/issues/queries";
|
||||
import { useWorkspaceId } from "@multica/core";
|
||||
import { StatusIcon } from "../issues/components";
|
||||
import { STATUS_CONFIG } from "@multica/core/issues/config";
|
||||
import { PROJECT_STATUS_CONFIG } from "@multica/core/projects/config";
|
||||
@@ -97,7 +100,18 @@ export function SearchCommand() {
|
||||
const { push } = useNavigation();
|
||||
const open = useSearchStore((s) => s.open);
|
||||
const setOpen = useSearchStore((s) => s.setOpen);
|
||||
const recentIssues = useRecentIssuesStore((s) => s.items);
|
||||
const recentItems = useRecentIssuesStore((s) => s.items);
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: allIssues = [] } = useQuery(issueListOptions(wsId));
|
||||
|
||||
const recentIssues = useMemo(() => {
|
||||
const issueMap = new Map(allIssues.map((i) => [i.id, i]));
|
||||
return recentItems.flatMap((item) => {
|
||||
const issue = issueMap.get(item.id);
|
||||
return issue ? [issue] : [];
|
||||
});
|
||||
}, [recentItems, allIssues]);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResults>({ issues: [], projects: [] });
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
Reference in New Issue
Block a user