feat: scope GitHub integration settings by workspace (#1572)
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { fetchUserSettings, updateUserSettings } from "@/lib/api/domains/settings-api";
|
||||
import {
|
||||
fetchGitHubWorkspaceSettings,
|
||||
updateGitHubWorkspaceSettings,
|
||||
} from "@/lib/api/domains/github-api";
|
||||
import {
|
||||
__resetSnapshotForTests,
|
||||
useDefaultQueryPresets,
|
||||
@@ -9,12 +13,19 @@ import {
|
||||
|
||||
const STORAGE_KEY = "kandev:github-default-queries:v1";
|
||||
const SYNC_FAILED_KEY = "kandev:github-default-queries:sync-failed:v1";
|
||||
const WORKSPACE_ID = "ws-1";
|
||||
const SETTINGS_TIMESTAMP = "2026-01-01T00:00:00Z";
|
||||
|
||||
vi.mock("@/lib/api/domains/settings-api", () => ({
|
||||
fetchUserSettings: vi.fn(),
|
||||
updateUserSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/domains/github-api", () => ({
|
||||
fetchGitHubWorkspaceSettings: vi.fn(),
|
||||
updateGitHubWorkspaceSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
function makeLocalStorageMock() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
@@ -31,6 +42,7 @@ function makeLocalStorageMock() {
|
||||
|
||||
const localStorageMock = makeLocalStorageMock();
|
||||
vi.stubGlobal("localStorage", localStorageMock);
|
||||
Object.defineProperty(window, "localStorage", { value: localStorageMock, configurable: true });
|
||||
|
||||
const preset: StoredQueryPreset = {
|
||||
value: "mine",
|
||||
@@ -39,18 +51,39 @@ const preset: StoredQueryPreset = {
|
||||
group: "created",
|
||||
};
|
||||
|
||||
describe("useDefaultQueryPresets", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorageMock.clear();
|
||||
__resetSnapshotForTests();
|
||||
vi.mocked(fetchUserSettings).mockResolvedValue({
|
||||
settings: { github_default_query_presets: null },
|
||||
} as Awaited<ReturnType<typeof fetchUserSettings>>);
|
||||
vi.mocked(updateUserSettings).mockResolvedValue({
|
||||
settings: {},
|
||||
} as Awaited<ReturnType<typeof updateUserSettings>>);
|
||||
});
|
||||
function workspaceSettings(
|
||||
defaultQueryPresets: unknown = null,
|
||||
): Awaited<ReturnType<typeof fetchGitHubWorkspaceSettings>> {
|
||||
return {
|
||||
workspace_id: WORKSPACE_ID,
|
||||
repo_scope_mode: "all",
|
||||
repo_scope_orgs: [],
|
||||
repo_scope_repos: [],
|
||||
saved_presets: [],
|
||||
default_query_presets: defaultQueryPresets,
|
||||
created_at: SETTINGS_TIMESTAMP,
|
||||
updated_at: SETTINGS_TIMESTAMP,
|
||||
} as Awaited<ReturnType<typeof fetchGitHubWorkspaceSettings>>;
|
||||
}
|
||||
|
||||
function resetMocks() {
|
||||
vi.clearAllMocks();
|
||||
localStorageMock.clear();
|
||||
__resetSnapshotForTests();
|
||||
vi.mocked(fetchUserSettings).mockResolvedValue({
|
||||
settings: { github_default_query_presets: null },
|
||||
} as Awaited<ReturnType<typeof fetchUserSettings>>);
|
||||
vi.mocked(updateUserSettings).mockResolvedValue({
|
||||
settings: {},
|
||||
} as Awaited<ReturnType<typeof updateUserSettings>>);
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockResolvedValue(workspaceSettings());
|
||||
vi.mocked(updateGitHubWorkspaceSettings).mockResolvedValue(
|
||||
{} as Awaited<ReturnType<typeof updateGitHubWorkspaceSettings>>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("useDefaultQueryPresets legacy sync", () => {
|
||||
beforeEach(resetMocks);
|
||||
|
||||
it("retries local defaults after a failed backend sync and clears the marker", async () => {
|
||||
const local = { pr: [preset], issue: [] };
|
||||
@@ -115,3 +148,59 @@ describe("useDefaultQueryPresets", () => {
|
||||
expect(updateUserSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDefaultQueryPresets workspace sync", () => {
|
||||
beforeEach(resetMocks);
|
||||
|
||||
it("migrates local defaults into a fresh workspace", async () => {
|
||||
const local = { pr: [preset], issue: [] };
|
||||
localStorageMock.setItem(STORAGE_KEY, JSON.stringify(local));
|
||||
__resetSnapshotForTests();
|
||||
|
||||
const { result } = renderHook(() => useDefaultQueryPresets(WORKSPACE_ID));
|
||||
|
||||
await waitFor(() => expect(result.current.prPresets[0]?.value).toBe("mine"));
|
||||
expect(updateGitHubWorkspaceSettings).toHaveBeenCalledWith({
|
||||
workspace_id: WORKSPACE_ID,
|
||||
default_query_presets: local,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not migrate local defaults over existing workspace defaults", async () => {
|
||||
const server = { pr: [{ ...preset, value: "server", label: "Server" }], issue: [] };
|
||||
localStorageMock.setItem(STORAGE_KEY, JSON.stringify({ pr: [preset], issue: [] }));
|
||||
__resetSnapshotForTests();
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockResolvedValue(workspaceSettings(server));
|
||||
|
||||
const { result } = renderHook(() => useDefaultQueryPresets(WORKSPACE_ID));
|
||||
|
||||
await waitFor(() => expect(result.current.prPresets[0]?.value).toBe("server"));
|
||||
expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not save or reset while workspace defaults are still loading", () => {
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockReturnValue(new Promise(() => {}));
|
||||
const { result } = renderHook(() => useDefaultQueryPresets(WORKSPACE_ID));
|
||||
|
||||
act(() => {
|
||||
result.current.save({ pr: [preset], issue: [] });
|
||||
result.current.reset();
|
||||
});
|
||||
|
||||
expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not save or reset after workspace defaults fail to load", async () => {
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockRejectedValue(new Error("settings down"));
|
||||
const { result } = renderHook(() => useDefaultQueryPresets(WORKSPACE_ID));
|
||||
|
||||
await waitFor(() => expect(fetchGitHubWorkspaceSettings).toHaveBeenCalled());
|
||||
|
||||
act(() => {
|
||||
result.current.save({ pr: [preset], issue: [] });
|
||||
result.current.reset();
|
||||
});
|
||||
|
||||
expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import {
|
||||
PR_PRESETS as BUILTIN_PR_PRESETS,
|
||||
ISSUE_PRESETS as BUILTIN_ISSUE_PRESETS,
|
||||
type PresetOption,
|
||||
} from "./search-bar";
|
||||
import { fetchUserSettings } from "@/lib/api/domains/settings-api";
|
||||
import {
|
||||
fetchGitHubWorkspaceSettings,
|
||||
updateGitHubWorkspaceSettings,
|
||||
} from "@/lib/api/domains/github-api";
|
||||
import { createQueuedUserSettingsSync } from "@/lib/user-settings-sync";
|
||||
import { hasUserSettingsSyncFailure } from "@/lib/user-settings-sync-failure";
|
||||
|
||||
const STORAGE_KEY = "kandev:github-default-queries:v1";
|
||||
const MIGRATED_KEY = "kandev:github-default-queries:migrated-to-backend:v1";
|
||||
const WORKSPACE_MIGRATED_KEY_PREFIX = "kandev:github-default-queries:migrated-to-workspace:v1:";
|
||||
const SYNC_FAILED_KEY = "kandev:github-default-queries:sync-failed:v1";
|
||||
|
||||
export type StoredQueryPreset = {
|
||||
@@ -84,6 +89,16 @@ function readServerDefaults(value: unknown): StoredDefaults | null | undefined {
|
||||
return value as StoredDefaults;
|
||||
}
|
||||
|
||||
async function readLegacyServerDefaults(): Promise<StoredDefaults | null | undefined> {
|
||||
try {
|
||||
const response = await fetchUserSettings({ cache: "no-store" });
|
||||
const defaults = readServerDefaults(response.settings.github_default_query_presets);
|
||||
return defaults === undefined ? null : defaults;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const syncServer = createQueuedUserSettingsSync<StoredDefaults | null>(
|
||||
SYNC_FAILED_KEY,
|
||||
(defaults) => ({
|
||||
@@ -91,6 +106,23 @@ const syncServer = createQueuedUserSettingsSync<StoredDefaults | null>(
|
||||
}),
|
||||
);
|
||||
|
||||
let workspaceSyncQueue = Promise.resolve();
|
||||
|
||||
function syncWorkspaceDefaultQueryPresets(
|
||||
workspaceId: string,
|
||||
defaults: StoredDefaults | null,
|
||||
): Promise<void> {
|
||||
workspaceSyncQueue = workspaceSyncQueue
|
||||
.catch(() => undefined)
|
||||
.then(() =>
|
||||
updateGitHubWorkspaceSettings({
|
||||
workspace_id: workspaceId,
|
||||
default_query_presets: defaults,
|
||||
}).then(() => undefined),
|
||||
);
|
||||
return workspaceSyncQueue;
|
||||
}
|
||||
|
||||
function snapshotKey(value: StoredDefaults | null): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
@@ -109,6 +141,20 @@ function markMigratedToBackend(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function hasMigratedToWorkspace(workspaceId: string): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.localStorage.getItem(WORKSPACE_MIGRATED_KEY_PREFIX + workspaceId) === "1";
|
||||
}
|
||||
|
||||
function markMigratedToWorkspace(workspaceId: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(WORKSPACE_MIGRATED_KEY_PREFIX + workspaceId, "1");
|
||||
} catch {
|
||||
/* ignore storage failures */
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(cb: () => void) {
|
||||
listeners.add(cb);
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
@@ -137,10 +183,9 @@ export function __resetSnapshotForTests() {
|
||||
listeners.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
export function useDefaultQueryPresets() {
|
||||
const stored = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
|
||||
function useLegacyDefaultQueryPresetSync(enabled: boolean) {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
const initialKey = snapshotKey(getSnapshot());
|
||||
fetchUserSettings({ cache: "no-store" })
|
||||
@@ -165,24 +210,108 @@ export function useDefaultQueryPresets() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [enabled]);
|
||||
}
|
||||
|
||||
const prPresets = useMemo(() => stored?.pr ?? toStored(BUILTIN_PR_PRESETS), [stored]);
|
||||
const issuePresets = useMemo(() => stored?.issue ?? toStored(BUILTIN_ISSUE_PRESETS), [stored]);
|
||||
|
||||
const save = useCallback((defaults: StoredDefaults) => {
|
||||
publish(defaults);
|
||||
void syncServer(defaults);
|
||||
markMigratedToBackend();
|
||||
function useWorkspaceDefaultQueryPresets(workspaceId: string | null) {
|
||||
const [workspaceDefaults, setWorkspaceDefaults] = useState<StoredDefaults | null | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const writeSeq = useRef(0);
|
||||
useEffect(() => {
|
||||
if (!workspaceId) {
|
||||
setWorkspaceDefaults(undefined);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const seq = writeSeq.current;
|
||||
setWorkspaceDefaults(undefined);
|
||||
fetchGitHubWorkspaceSettings(workspaceId)
|
||||
.then(async (settings) => {
|
||||
if (cancelled || seq !== writeSeq.current) return;
|
||||
const defaults = readServerDefaults(settings.default_query_presets);
|
||||
let local: StoredDefaults | null | undefined = getSnapshot();
|
||||
if (local === null) {
|
||||
local = await readLegacyServerDefaults();
|
||||
}
|
||||
if (cancelled || seq !== writeSeq.current) return;
|
||||
if (
|
||||
(defaults === null || defaults === undefined) &&
|
||||
local !== undefined &&
|
||||
local !== null &&
|
||||
!hasMigratedToWorkspace(workspaceId)
|
||||
) {
|
||||
setWorkspaceDefaults(local);
|
||||
void syncWorkspaceDefaultQueryPresets(workspaceId, local)
|
||||
.then(() => markMigratedToWorkspace(workspaceId))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
setWorkspaceDefaults(defaults === undefined ? null : defaults);
|
||||
if (!(local === undefined && (defaults === null || defaults === undefined))) {
|
||||
markMigratedToWorkspace(workspaceId);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setWorkspaceDefaults(undefined);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workspaceId]);
|
||||
const setWorkspaceDefaultsFromLocal = useCallback((next: StoredDefaults | null) => {
|
||||
writeSeq.current += 1;
|
||||
setWorkspaceDefaults(next);
|
||||
}, []);
|
||||
return { workspaceDefaults, setWorkspaceDefaults: setWorkspaceDefaultsFromLocal };
|
||||
}
|
||||
|
||||
export function useDefaultQueryPresets(workspaceId: string | null = null) {
|
||||
const stored = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
const { workspaceDefaults, setWorkspaceDefaults } = useWorkspaceDefaultQueryPresets(workspaceId);
|
||||
useLegacyDefaultQueryPresetSync(!workspaceId);
|
||||
const effectiveStored = workspaceId ? workspaceDefaults : stored;
|
||||
const prPresets = useMemo(
|
||||
() => effectiveStored?.pr ?? toStored(BUILTIN_PR_PRESETS),
|
||||
[effectiveStored],
|
||||
);
|
||||
const issuePresets = useMemo(
|
||||
() => effectiveStored?.issue ?? toStored(BUILTIN_ISSUE_PRESETS),
|
||||
[effectiveStored],
|
||||
);
|
||||
|
||||
const save = useCallback(
|
||||
(defaults: StoredDefaults) => {
|
||||
if (workspaceId && workspaceDefaults === undefined) return;
|
||||
if (workspaceId) {
|
||||
setWorkspaceDefaults(defaults);
|
||||
void syncWorkspaceDefaultQueryPresets(workspaceId, defaults)
|
||||
.then(() => markMigratedToWorkspace(workspaceId))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
publish(defaults);
|
||||
void syncServer(defaults);
|
||||
markMigratedToBackend();
|
||||
},
|
||||
[workspaceId, workspaceDefaults, setWorkspaceDefaults],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
if (workspaceId && workspaceDefaults === undefined) return;
|
||||
if (workspaceId) {
|
||||
setWorkspaceDefaults(null);
|
||||
void syncWorkspaceDefaultQueryPresets(workspaceId, null)
|
||||
.then(() => markMigratedToWorkspace(workspaceId))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
publish(null);
|
||||
void syncServer(null);
|
||||
markMigratedToBackend();
|
||||
}, []);
|
||||
}, [workspaceId, workspaceDefaults, setWorkspaceDefaults]);
|
||||
|
||||
const isCustomized = stored !== null;
|
||||
const isCustomized = effectiveStored !== null && effectiveStored !== undefined;
|
||||
|
||||
return { prPresets, issuePresets, save, reset, isCustomized };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { searchUserPRs, searchUserIssues } from "@/lib/api/domains/github-api";
|
||||
import type { GitHubPR, GitHubIssue } from "@/lib/types/github";
|
||||
import type { PresetOption } from "./search-bar";
|
||||
@@ -22,6 +22,16 @@ type FetchArgs = {
|
||||
customQuery: string;
|
||||
repoFilter: string;
|
||||
page: number;
|
||||
workspaceId: string | null;
|
||||
};
|
||||
|
||||
type UseGitHubSearchOptions = {
|
||||
kind: SearchKind;
|
||||
presets: PresetOption[];
|
||||
preset: string;
|
||||
customQuery: string;
|
||||
repoFilter?: string;
|
||||
workspaceId?: string | null;
|
||||
};
|
||||
|
||||
export function buildParams(
|
||||
@@ -40,13 +50,14 @@ export function buildParams(
|
||||
return { filter };
|
||||
}
|
||||
|
||||
export function useGitHubSearch<T extends GitHubPR | GitHubIssue>(
|
||||
kind: SearchKind,
|
||||
presets: PresetOption[],
|
||||
preset: string,
|
||||
customQuery: string,
|
||||
repoFilter: string = "",
|
||||
) {
|
||||
export function useGitHubSearch<T extends GitHubPR | GitHubIssue>({
|
||||
kind,
|
||||
presets,
|
||||
preset,
|
||||
customQuery,
|
||||
repoFilter = "",
|
||||
workspaceId = null,
|
||||
}: UseGitHubSearchOptions) {
|
||||
const [state, setState] = useState<SearchState<T>>({
|
||||
items: [],
|
||||
loading: false,
|
||||
@@ -59,22 +70,33 @@ export function useGitHubSearch<T extends GitHubPR | GitHubIssue>(
|
||||
// a slow page-N request can't overwrite a fresher page-1 result after a
|
||||
// filter change.
|
||||
const requestSeq = useRef(0);
|
||||
const workspaceRef = useRef(workspaceId);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
workspaceRef.current = workspaceId;
|
||||
}, [workspaceId]);
|
||||
|
||||
// Reset to page 1 whenever the filter inputs change.
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [preset, customQuery, repoFilter, kind]);
|
||||
}, [preset, customQuery, repoFilter, workspaceId, kind]);
|
||||
|
||||
const fetchData = useCallback(
|
||||
async ({ preset: ep, customQuery: ec, repoFilter: er, page: epage }: FetchArgs) => {
|
||||
async ({
|
||||
preset: ep,
|
||||
customQuery: ec,
|
||||
repoFilter: er,
|
||||
page: epage,
|
||||
workspaceId: ews,
|
||||
}: FetchArgs) => {
|
||||
const seq = ++requestSeq.current;
|
||||
setState((s) => ({ ...s, loading: true, error: null }));
|
||||
try {
|
||||
const base = buildParams(presets, ep, ec, er);
|
||||
const params = { ...base, page: epage, perPage: SEARCH_PAGE_SIZE };
|
||||
const params = { ...base, page: epage, perPage: SEARCH_PAGE_SIZE, workspaceId: ews };
|
||||
const response =
|
||||
kind === "pr" ? await searchUserPRs(params) : await searchUserIssues(params);
|
||||
if (seq !== requestSeq.current) return;
|
||||
if (seq !== requestSeq.current || ews !== workspaceRef.current) return;
|
||||
const items = (kind === "pr"
|
||||
? (response as { prs: GitHubPR[] }).prs
|
||||
: (response as { issues: GitHubIssue[] }).issues) as unknown as T[];
|
||||
@@ -86,7 +108,7 @@ export function useGitHubSearch<T extends GitHubPR | GitHubIssue>(
|
||||
total: response.total_count ?? (items ?? []).length,
|
||||
});
|
||||
} catch (err) {
|
||||
if (seq !== requestSeq.current) return;
|
||||
if (seq !== requestSeq.current || ews !== workspaceRef.current) return;
|
||||
setState((s) => ({
|
||||
items: [],
|
||||
loading: false,
|
||||
@@ -100,12 +122,12 @@ export function useGitHubSearch<T extends GitHubPR | GitHubIssue>(
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchData({ preset, customQuery, repoFilter, page });
|
||||
}, [fetchData, preset, customQuery, repoFilter, page]);
|
||||
void fetchData({ preset, customQuery, repoFilter, page, workspaceId });
|
||||
}, [fetchData, preset, customQuery, repoFilter, page, workspaceId]);
|
||||
|
||||
const refresh = useCallback(
|
||||
() => fetchData({ preset, customQuery, repoFilter, page }),
|
||||
[fetchData, preset, customQuery, repoFilter, page],
|
||||
() => fetchData({ preset, customQuery, repoFilter, page, workspaceId }),
|
||||
[fetchData, preset, customQuery, repoFilter, page, workspaceId],
|
||||
);
|
||||
|
||||
return { ...state, page, setPage, pageSize: SEARCH_PAGE_SIZE, refresh };
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { fetchUserSettings, updateUserSettings } from "@/lib/api/domains/settings-api";
|
||||
import {
|
||||
fetchGitHubWorkspaceSettings,
|
||||
updateGitHubWorkspaceSettings,
|
||||
} from "@/lib/api/domains/github-api";
|
||||
import {
|
||||
__resetSnapshotForTests,
|
||||
readStorage,
|
||||
@@ -10,12 +14,19 @@ import {
|
||||
|
||||
const STORAGE_KEY = "kandev:github-presets:v1";
|
||||
const SYNC_FAILED_KEY = "kandev:github-presets:sync-failed:v1";
|
||||
const WORKSPACE_ID = "ws-1";
|
||||
const SETTINGS_TIMESTAMP = "2026-01-01T00:00:00Z";
|
||||
|
||||
vi.mock("@/lib/api/domains/settings-api", () => ({
|
||||
fetchUserSettings: vi.fn(),
|
||||
updateUserSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/domains/github-api", () => ({
|
||||
fetchGitHubWorkspaceSettings: vi.fn(),
|
||||
updateGitHubWorkspaceSettings: vi.fn(),
|
||||
}));
|
||||
|
||||
// Provide a simple in-memory localStorage mock so the tests are not sensitive
|
||||
// to how the test runner exposes window.localStorage (e.g. Node's
|
||||
// --localstorage-file flag without a valid path).
|
||||
@@ -35,6 +46,7 @@ function makeLocalStorageMock() {
|
||||
|
||||
const localStorageMock = makeLocalStorageMock();
|
||||
vi.stubGlobal("localStorage", localStorageMock);
|
||||
Object.defineProperty(window, "localStorage", { value: localStorageMock, configurable: true });
|
||||
|
||||
function set(raw: string | null) {
|
||||
if (raw === null) localStorageMock.removeItem(STORAGE_KEY);
|
||||
@@ -47,15 +59,36 @@ const valid: SavedPreset = {
|
||||
label: "My PRs",
|
||||
customQuery: "author:@me",
|
||||
repoFilter: "",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
createdAt: SETTINGS_TIMESTAMP,
|
||||
};
|
||||
|
||||
function resetTestState() {
|
||||
localStorageMock.clear();
|
||||
__resetSnapshotForTests();
|
||||
vi.mocked(fetchUserSettings).mockReset();
|
||||
vi.mocked(updateUserSettings).mockReset();
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockReset();
|
||||
vi.mocked(updateGitHubWorkspaceSettings).mockReset();
|
||||
}
|
||||
|
||||
function workspaceSettings(
|
||||
savedPresets: SavedPreset[] = [],
|
||||
): Awaited<ReturnType<typeof fetchGitHubWorkspaceSettings>> {
|
||||
return {
|
||||
workspace_id: WORKSPACE_ID,
|
||||
repo_scope_mode: "all",
|
||||
repo_scope_orgs: [],
|
||||
repo_scope_repos: [],
|
||||
saved_presets: savedPresets,
|
||||
default_query_presets: null,
|
||||
created_at: SETTINGS_TIMESTAMP,
|
||||
updated_at: SETTINGS_TIMESTAMP,
|
||||
} as Awaited<ReturnType<typeof fetchGitHubWorkspaceSettings>>;
|
||||
}
|
||||
|
||||
describe("readStorage", () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
__resetSnapshotForTests();
|
||||
vi.mocked(fetchUserSettings).mockReset();
|
||||
vi.mocked(updateUserSettings).mockReset();
|
||||
resetTestState();
|
||||
});
|
||||
|
||||
it("returns empty array when no value is stored", () => {
|
||||
@@ -108,10 +141,7 @@ describe("readStorage", () => {
|
||||
|
||||
describe("useSavedPresets", () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
__resetSnapshotForTests();
|
||||
vi.mocked(fetchUserSettings).mockReset();
|
||||
vi.mocked(updateUserSettings).mockReset();
|
||||
resetTestState();
|
||||
});
|
||||
|
||||
it("retries local presets after a failed backend sync and clears the marker", async () => {
|
||||
@@ -150,3 +180,102 @@ describe("useSavedPresets", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSavedPresets workspace sync", () => {
|
||||
beforeEach(() => {
|
||||
resetTestState();
|
||||
});
|
||||
|
||||
it("migrates local presets into a fresh workspace", async () => {
|
||||
set(JSON.stringify([valid]));
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockResolvedValue(workspaceSettings());
|
||||
vi.mocked(updateGitHubWorkspaceSettings).mockResolvedValue(
|
||||
{} as Awaited<ReturnType<typeof updateGitHubWorkspaceSettings>>,
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
|
||||
|
||||
await waitFor(() => expect(result.current.presets).toEqual([valid]));
|
||||
expect(updateGitHubWorkspaceSettings).toHaveBeenCalledWith({
|
||||
workspace_id: WORKSPACE_ID,
|
||||
saved_presets: [valid],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not migrate local presets over existing workspace presets", async () => {
|
||||
const server = { ...valid, id: "p_server", label: "Server" };
|
||||
set(JSON.stringify([valid]));
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockResolvedValue(workspaceSettings([server]));
|
||||
|
||||
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
|
||||
|
||||
await waitFor(() => expect(result.current.presets).toEqual([server]));
|
||||
expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not save while workspace presets are still loading", () => {
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
|
||||
|
||||
let created: SavedPreset | null = null;
|
||||
act(() => {
|
||||
created = result.current.save({
|
||||
kind: "pr",
|
||||
label: "Loading",
|
||||
customQuery: "is:open",
|
||||
repoFilter: "",
|
||||
});
|
||||
});
|
||||
|
||||
expect(created).toBeNull();
|
||||
expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not save after workspace presets fail to load", async () => {
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockRejectedValue(new Error("settings down"));
|
||||
|
||||
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
|
||||
|
||||
await waitFor(() => expect(fetchGitHubWorkspaceSettings).toHaveBeenCalled());
|
||||
|
||||
let created: SavedPreset | null = valid;
|
||||
act(() => {
|
||||
created = result.current.save({
|
||||
kind: "pr",
|
||||
label: "Failed load",
|
||||
customQuery: "is:open",
|
||||
repoFilter: "",
|
||||
});
|
||||
});
|
||||
|
||||
expect(created).toBeNull();
|
||||
expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not remove while workspace presets are still loading", () => {
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockReturnValue(new Promise(() => {}));
|
||||
|
||||
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
|
||||
|
||||
act(() => {
|
||||
result.current.remove("p_1");
|
||||
});
|
||||
|
||||
expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not remove after workspace presets fail to load", async () => {
|
||||
vi.mocked(fetchGitHubWorkspaceSettings).mockRejectedValue(new Error("settings down"));
|
||||
|
||||
const { result } = renderHook(() => useSavedPresets(WORKSPACE_ID));
|
||||
|
||||
await waitFor(() => expect(fetchGitHubWorkspaceSettings).toHaveBeenCalled());
|
||||
|
||||
act(() => {
|
||||
result.current.remove("p_1");
|
||||
});
|
||||
|
||||
expect(updateGitHubWorkspaceSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useSyncExternalStore } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { fetchUserSettings } from "@/lib/api/domains/settings-api";
|
||||
import {
|
||||
fetchGitHubWorkspaceSettings,
|
||||
updateGitHubWorkspaceSettings,
|
||||
} from "@/lib/api/domains/github-api";
|
||||
import { createQueuedUserSettingsSync } from "@/lib/user-settings-sync";
|
||||
import { hasUserSettingsSyncFailure } from "@/lib/user-settings-sync-failure";
|
||||
|
||||
const STORAGE_KEY = "kandev:github-presets:v1";
|
||||
const MIGRATED_KEY = "kandev:github-presets:migrated-to-backend:v1";
|
||||
const WORKSPACE_MIGRATED_KEY_PREFIX = "kandev:github-presets:migrated-to-workspace:v1:";
|
||||
const SYNC_FAILED_KEY = "kandev:github-presets:sync-failed:v1";
|
||||
|
||||
export type SavedPreset = {
|
||||
@@ -86,10 +91,33 @@ function readServerPresets(value: unknown): SavedPreset[] | null {
|
||||
);
|
||||
}
|
||||
|
||||
async function readLegacyServerPresets(): Promise<SavedPreset[] | undefined> {
|
||||
try {
|
||||
const response = await fetchUserSettings({ cache: "no-store" });
|
||||
return readServerPresets(response.settings.github_saved_presets) ?? [];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const syncServer = createQueuedUserSettingsSync<SavedPreset[]>(SYNC_FAILED_KEY, (next) => ({
|
||||
github_saved_presets: next,
|
||||
}));
|
||||
|
||||
let workspaceSyncQueue = Promise.resolve();
|
||||
|
||||
function syncWorkspaceSavedPresets(workspaceId: string, next: SavedPreset[]): Promise<void> {
|
||||
workspaceSyncQueue = workspaceSyncQueue
|
||||
.catch(() => undefined)
|
||||
.then(() =>
|
||||
updateGitHubWorkspaceSettings({
|
||||
workspace_id: workspaceId,
|
||||
saved_presets: next,
|
||||
}).then(() => undefined),
|
||||
);
|
||||
return workspaceSyncQueue;
|
||||
}
|
||||
|
||||
function snapshotKey(value: SavedPreset[]): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
@@ -108,15 +136,28 @@ function markMigratedToBackend(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function hasMigratedToWorkspace(workspaceId: string): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.localStorage.getItem(WORKSPACE_MIGRATED_KEY_PREFIX + workspaceId) === "1";
|
||||
}
|
||||
|
||||
function markMigratedToWorkspace(workspaceId: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(WORKSPACE_MIGRATED_KEY_PREFIX + workspaceId, "1");
|
||||
} catch {
|
||||
/* ignore storage failures */
|
||||
}
|
||||
}
|
||||
|
||||
export function __resetSnapshotForTests() {
|
||||
snapshot = null;
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
export function useSavedPresets() {
|
||||
const presets = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
|
||||
function useLegacySavedPresetsSync(enabled: boolean) {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
const initialKey = snapshotKey(readStorage());
|
||||
fetchUserSettings({ cache: "no-store" })
|
||||
@@ -141,27 +182,107 @@ export function useSavedPresets() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const save = useCallback((input: Omit<SavedPreset, "id" | "createdAt">) => {
|
||||
const preset: SavedPreset = {
|
||||
...input,
|
||||
id: `p_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const next = [...readStorage(), preset];
|
||||
publish(next);
|
||||
void syncServer(next);
|
||||
markMigratedToBackend();
|
||||
return preset;
|
||||
}, []);
|
||||
|
||||
const remove = useCallback((id: string) => {
|
||||
const next = readStorage().filter((p) => p.id !== id);
|
||||
publish(next);
|
||||
void syncServer(next);
|
||||
markMigratedToBackend();
|
||||
}, []);
|
||||
|
||||
return { presets, save, remove };
|
||||
}, [enabled]);
|
||||
}
|
||||
|
||||
function useWorkspaceSavedPresets(workspaceId: string | null) {
|
||||
const [workspacePresets, setWorkspacePresets] = useState<SavedPreset[] | undefined>(undefined);
|
||||
const writeSeq = useRef(0);
|
||||
useEffect(() => {
|
||||
if (!workspaceId) {
|
||||
setWorkspacePresets(undefined);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const seq = writeSeq.current;
|
||||
setWorkspacePresets(undefined);
|
||||
fetchGitHubWorkspaceSettings(workspaceId)
|
||||
.then(async (settings) => {
|
||||
if (cancelled || seq !== writeSeq.current) return;
|
||||
const serverPresets = readServerPresets(settings.saved_presets) ?? [];
|
||||
let local: SavedPreset[] | undefined = readStorage();
|
||||
if (local.length === 0) {
|
||||
local = await readLegacyServerPresets();
|
||||
}
|
||||
if (cancelled || seq !== writeSeq.current) return;
|
||||
if (
|
||||
serverPresets.length === 0 &&
|
||||
local !== undefined &&
|
||||
local.length > 0 &&
|
||||
!hasMigratedToWorkspace(workspaceId)
|
||||
) {
|
||||
setWorkspacePresets(local);
|
||||
void syncWorkspaceSavedPresets(workspaceId, local)
|
||||
.then(() => markMigratedToWorkspace(workspaceId))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
setWorkspacePresets(serverPresets);
|
||||
if (local !== undefined || serverPresets.length > 0) {
|
||||
markMigratedToWorkspace(workspaceId);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setWorkspacePresets(undefined);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [workspaceId]);
|
||||
const setWorkspacePresetsFromLocal = useCallback((next: SavedPreset[]) => {
|
||||
writeSeq.current += 1;
|
||||
setWorkspacePresets(next);
|
||||
}, []);
|
||||
return { workspacePresets, setWorkspacePresets: setWorkspacePresetsFromLocal };
|
||||
}
|
||||
|
||||
export function useSavedPresets(workspaceId: string | null = null) {
|
||||
const presets = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
const { workspacePresets, setWorkspacePresets } = useWorkspaceSavedPresets(workspaceId);
|
||||
useLegacySavedPresetsSync(!workspaceId);
|
||||
const activePresets = workspaceId ? (workspacePresets ?? []) : presets;
|
||||
|
||||
const save = useCallback(
|
||||
(input: Omit<SavedPreset, "id" | "createdAt">) => {
|
||||
if (workspaceId && workspacePresets === undefined) return null;
|
||||
const preset: SavedPreset = {
|
||||
...input,
|
||||
id: `p_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const next = [...activePresets, preset];
|
||||
if (workspaceId) {
|
||||
setWorkspacePresets(next);
|
||||
void syncWorkspaceSavedPresets(workspaceId, next)
|
||||
.then(() => markMigratedToWorkspace(workspaceId))
|
||||
.catch(() => {});
|
||||
return preset;
|
||||
}
|
||||
publish(next);
|
||||
void syncServer(next);
|
||||
markMigratedToBackend();
|
||||
return preset;
|
||||
},
|
||||
[activePresets, workspaceId, workspacePresets, setWorkspacePresets],
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
(id: string) => {
|
||||
if (workspaceId && workspacePresets === undefined) return;
|
||||
const next = activePresets.filter((p) => p.id !== id);
|
||||
if (workspaceId) {
|
||||
setWorkspacePresets(next);
|
||||
void syncWorkspaceSavedPresets(workspaceId, next)
|
||||
.then(() => markMigratedToWorkspace(workspaceId))
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
publish(next);
|
||||
void syncServer(next);
|
||||
markMigratedToBackend();
|
||||
},
|
||||
[activePresets, workspaceId, workspacePresets, setWorkspacePresets],
|
||||
);
|
||||
|
||||
return { presets: activePresets, save, remove };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user