feat(FN-4731): add tracking repo dropdown to settings modal
Adds a `TrackingRepoSelect` dropdown component to the SettingsModal (with co-located CSS and full test coverage), updates the settings reference docs, and includes the FN-4731 changeset for the `@runfusion/fusion` package. Fusion-Task-Id: FN-4731
This commit is contained in:
committed by
gsxdsm
parent
e5c9c7a3d9
commit
51c60fe1b5
@@ -12,8 +12,8 @@ import {
|
||||
resolveTitleSummarizerSettingsModel,
|
||||
} from "@fusion/core";
|
||||
import type { AgentPermissionPolicyRules, Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
|
||||
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, installCloudflared, startRemoteTunnel, stopRemoteTunnel, killExternalTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api";
|
||||
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse, OAuthDeviceCodeInfo } from "../api";
|
||||
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchProjects, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, installCloudflared, startRemoteTunnel, stopRemoteTunnel, killExternalTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api";
|
||||
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteSettings, RemoteStatus, UpdateCheckResponse, OAuthDeviceCodeInfo } from "../api";
|
||||
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
@@ -51,6 +51,7 @@ import { useNodes } from "../hooks/useNodes";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { useWorktrunkInstallStatus } from "../hooks/useWorktrunkInstallStatus";
|
||||
import { NodeHealthDot } from "./NodeHealthDot";
|
||||
import { TrackingRepoSelect, type TrackingRepoOption } from "./TrackingRepoSelect";
|
||||
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -74,6 +75,17 @@ function getNodeStatusLabel(status: "online" | "offline" | "connecting" | "error
|
||||
return "Offline";
|
||||
}
|
||||
|
||||
function toTrackingRepoOptions(remotes: GitRemote[]): TrackingRepoOption[] {
|
||||
const byValue = new Map<string, TrackingRepoOption>();
|
||||
for (const remote of remotes) {
|
||||
const value = `${remote.owner}/${remote.repo}`;
|
||||
if (!byValue.has(value)) {
|
||||
byValue.set(value, { value, label: value });
|
||||
}
|
||||
}
|
||||
return [...byValue.values()].sort((a, b) => a.value.localeCompare(b.value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Has the user already clicked the "Star on GitHub" button at any point in
|
||||
* the past? Used to permanently hide the button afterward — clicking opens
|
||||
@@ -595,6 +607,13 @@ export function SettingsModal({
|
||||
// Git remotes for the worktree rebase dropdown. Loaded lazily; empty list
|
||||
// is a valid state (fresh repo, no remotes configured yet).
|
||||
const [gitRemotes, setGitRemotes] = useState<GitRemoteDetailed[]>([]);
|
||||
const [projectTrackingRepoOptions, setProjectTrackingRepoOptions] = useState<TrackingRepoOption[]>([]);
|
||||
const [projectTrackingRepoLoading, setProjectTrackingRepoLoading] = useState(false);
|
||||
const [projectTrackingRepoError, setProjectTrackingRepoError] = useState<string | null>(null);
|
||||
const [globalTrackingRepoOptions, setGlobalTrackingRepoOptions] = useState<TrackingRepoOption[]>([]);
|
||||
const [globalTrackingRepoLoading, setGlobalTrackingRepoLoading] = useState(false);
|
||||
const [globalTrackingRepoError, setGlobalTrackingRepoError] = useState<string | null>(null);
|
||||
const globalTrackingRepoLoadedRef = useRef(false);
|
||||
const [memoryFiles, setMemoryFiles] = useState<MemoryFileInfo[]>([]);
|
||||
const [selectedMemoryPath, setSelectedMemoryPath] = useState(DEFAULT_MEMORY_EDITOR_PATH);
|
||||
const [memoryTestQuery, setMemoryTestQuery] = useState("");
|
||||
@@ -927,6 +946,109 @@ export function SettingsModal({
|
||||
.catch(() => setGitRemotes([]));
|
||||
}, [activeSection, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection !== "general") {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setProjectTrackingRepoLoading(true);
|
||||
setProjectTrackingRepoError(null);
|
||||
|
||||
fetchGitRemotes(projectId)
|
||||
.then((remotes) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setProjectTrackingRepoOptions(toTrackingRepoOptions(remotes));
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setProjectTrackingRepoOptions([]);
|
||||
setProjectTrackingRepoError("Could not load detected remotes. Enter a custom owner/repo value.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setProjectTrackingRepoLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeSection, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection !== "global-general" || globalTrackingRepoLoadedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setGlobalTrackingRepoLoading(true);
|
||||
setGlobalTrackingRepoError(null);
|
||||
|
||||
fetchProjects()
|
||||
.then(async (projects) => {
|
||||
const results = await Promise.allSettled(
|
||||
projects.map(async (project: ProjectInfo) => {
|
||||
const remotes = await fetchGitRemotes(project.id);
|
||||
return remotes.map((remote) => ({
|
||||
value: `${remote.owner}/${remote.repo}`,
|
||||
label: `${remote.owner}/${remote.repo}`,
|
||||
source: project.name,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const optionsByValue = new Map<string, TrackingRepoOption>();
|
||||
let successCount = 0;
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status !== "fulfilled") {
|
||||
continue;
|
||||
}
|
||||
successCount += 1;
|
||||
for (const option of result.value) {
|
||||
if (!optionsByValue.has(option.value)) {
|
||||
optionsByValue.set(option.value, option);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const flattenedOptions = [...optionsByValue.values()].sort((a, b) => a.value.localeCompare(b.value));
|
||||
setGlobalTrackingRepoOptions(flattenedOptions);
|
||||
|
||||
if (projects.length > 0 && successCount === 0) {
|
||||
setGlobalTrackingRepoError("Could not load remotes from registered projects. Enter a custom owner/repo value.");
|
||||
}
|
||||
|
||||
globalTrackingRepoLoadedRef.current = true;
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setGlobalTrackingRepoOptions([]);
|
||||
setGlobalTrackingRepoError("Could not load project list. Enter a custom owner/repo value.");
|
||||
globalTrackingRepoLoadedRef.current = true;
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setGlobalTrackingRepoLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeSection, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection !== "memory" || memoryDirty) {
|
||||
return;
|
||||
@@ -2279,14 +2401,16 @@ export function SettingsModal({
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="projectGithubTrackingDefaultRepoGeneral">Project default tracking repo</label>
|
||||
<input
|
||||
<TrackingRepoSelect
|
||||
id="projectGithubTrackingDefaultRepoGeneral"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="owner/repo"
|
||||
ariaLabel="Project default tracking repo"
|
||||
value={form.githubTrackingDefaultRepo ?? ""}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, githubTrackingDefaultRepo: e.target.value || undefined }))
|
||||
options={projectTrackingRepoOptions}
|
||||
loading={projectTrackingRepoLoading}
|
||||
error={projectTrackingRepoError ?? undefined}
|
||||
placeholder="owner/repo"
|
||||
onChange={(nextValue) =>
|
||||
setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined }))
|
||||
}
|
||||
/>
|
||||
<small>Default repo used when creating GitHub issues for tracked tasks. Falls back to the global default if blank.</small>
|
||||
@@ -2317,14 +2441,16 @@ export function SettingsModal({
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="globalGithubTrackingDefaultRepo">Global default tracking repo</label>
|
||||
<input
|
||||
<TrackingRepoSelect
|
||||
id="globalGithubTrackingDefaultRepo"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="owner/repo"
|
||||
ariaLabel="Global default tracking repo"
|
||||
value={form.githubTrackingDefaultRepo ?? ""}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, githubTrackingDefaultRepo: e.target.value || undefined }))
|
||||
options={globalTrackingRepoOptions}
|
||||
loading={globalTrackingRepoLoading}
|
||||
error={globalTrackingRepoError ?? undefined}
|
||||
placeholder="owner/repo"
|
||||
onChange={(nextValue) =>
|
||||
setForm((f) => ({ ...f, githubTrackingDefaultRepo: nextValue || undefined }))
|
||||
}
|
||||
/>
|
||||
<small>Projects inherit this value when they do not set a project default tracking repo.</small>
|
||||
|
||||
23
packages/dashboard/app/components/TrackingRepoSelect.css
Normal file
23
packages/dashboard/app/components/TrackingRepoSelect.css
Normal file
@@ -0,0 +1,23 @@
|
||||
.tracking-repo-select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.tracking-repo-select__custom-input {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.tracking-repo-select__hint {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.tracking-repo-select__hint--error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tracking-repo-select {
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
}
|
||||
114
packages/dashboard/app/components/TrackingRepoSelect.tsx
Normal file
114
packages/dashboard/app/components/TrackingRepoSelect.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import "./TrackingRepoSelect.css";
|
||||
|
||||
const CUSTOM_VALUE = "__custom__";
|
||||
|
||||
export interface TrackingRepoOption {
|
||||
value: string;
|
||||
label: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
interface TrackingRepoSelectProps {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: TrackingRepoOption[];
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
placeholder?: string;
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
function normalizeOptions(options: TrackingRepoOption[]): TrackingRepoOption[] {
|
||||
const byValue = new Map<string, TrackingRepoOption>();
|
||||
for (const option of options) {
|
||||
const normalized = option.value.trim();
|
||||
if (!normalized || byValue.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
byValue.set(normalized, {
|
||||
...option,
|
||||
value: normalized,
|
||||
label: option.label?.trim() || normalized,
|
||||
});
|
||||
}
|
||||
return [...byValue.values()].sort((a, b) => a.value.localeCompare(b.value));
|
||||
}
|
||||
|
||||
export function TrackingRepoSelect({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
loading = false,
|
||||
error,
|
||||
placeholder = "owner/repo",
|
||||
ariaLabel,
|
||||
}: TrackingRepoSelectProps) {
|
||||
const customInputRef = useRef<HTMLInputElement>(null);
|
||||
const hintId = useId();
|
||||
const [showCustomInput, setShowCustomInput] = useState(false);
|
||||
|
||||
const normalizedOptions = useMemo(() => normalizeOptions(options), [options]);
|
||||
const selectedOption = normalizedOptions.find((option) => option.value === value);
|
||||
const selectValue = selectedOption ? selectedOption.value : CUSTOM_VALUE;
|
||||
|
||||
useEffect(() => {
|
||||
setShowCustomInput(selectValue === CUSTOM_VALUE);
|
||||
}, [selectValue]);
|
||||
|
||||
const handleSelectChange = (nextValue: string) => {
|
||||
if (nextValue === CUSTOM_VALUE) {
|
||||
setShowCustomInput(true);
|
||||
requestAnimationFrame(() => {
|
||||
customInputRef.current?.focus();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setShowCustomInput(false);
|
||||
onChange(nextValue);
|
||||
};
|
||||
|
||||
const renderedHint = error ? error : loading ? "Loading detected GitHub remotes…" : null;
|
||||
|
||||
return (
|
||||
<div className="tracking-repo-select">
|
||||
<select
|
||||
id={id}
|
||||
className="select"
|
||||
value={selectValue}
|
||||
onChange={(event) => handleSelectChange(event.target.value)}
|
||||
aria-label={ariaLabel}
|
||||
aria-describedby={renderedHint ? hintId : undefined}
|
||||
>
|
||||
{normalizedOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.source ? `${option.label} — ${option.source}` : option.label}
|
||||
</option>
|
||||
))}
|
||||
<option value={CUSTOM_VALUE}>Custom…</option>
|
||||
</select>
|
||||
{showCustomInput ? (
|
||||
<input
|
||||
ref={customInputRef}
|
||||
className="input tracking-repo-select__custom-input"
|
||||
type="text"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
aria-describedby={renderedHint ? hintId : undefined}
|
||||
/>
|
||||
) : null}
|
||||
{renderedHint ? (
|
||||
<small
|
||||
id={hintId}
|
||||
className={error ? "tracking-repo-select__hint tracking-repo-select__hint--error" : "tracking-repo-select__hint"}
|
||||
>
|
||||
{renderedHint}
|
||||
</small>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,9 @@ const mockUpdateGlobalConcurrency = vi.fn();
|
||||
const mockFetchMemoryBackendStatus = vi.fn();
|
||||
const mockTestMemoryRetrieval = vi.fn();
|
||||
const mockInstallQmd = vi.fn();
|
||||
const mockFetchGitRemotes = vi.fn();
|
||||
const mockFetchGitRemotesDetailed = vi.fn();
|
||||
const mockFetchProjects = vi.fn();
|
||||
const mockFetchDashboardHealth = vi.fn();
|
||||
const mockCheckForUpdates = vi.fn();
|
||||
const mockFetchRemoteSettings = vi.fn();
|
||||
@@ -95,7 +97,9 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
fetchMemoryBackendStatus: (...args: unknown[]) => mockFetchMemoryBackendStatus(...args),
|
||||
testMemoryRetrieval: (...args: unknown[]) => mockTestMemoryRetrieval(...args),
|
||||
installQmd: (...args: unknown[]) => mockInstallQmd(...args),
|
||||
fetchGitRemotes: (...args: unknown[]) => mockFetchGitRemotes(...args),
|
||||
fetchGitRemotesDetailed: (...args: unknown[]) => mockFetchGitRemotesDetailed(...args),
|
||||
fetchProjects: (...args: unknown[]) => mockFetchProjects(...args),
|
||||
fetchDashboardHealth: (...args: unknown[]) => mockFetchDashboardHealth(...args),
|
||||
checkForUpdates: (...args: unknown[]) => mockCheckForUpdates(...args),
|
||||
fetchRemoteSettings: (...args: unknown[]) => mockFetchRemoteSettings(...args),
|
||||
@@ -409,7 +413,9 @@ describe("SettingsModal", () => {
|
||||
qmdAvailable: true,
|
||||
qmdInstallCommand: "bun install -g @tobilu/qmd",
|
||||
});
|
||||
mockFetchGitRemotes.mockResolvedValue([]);
|
||||
mockFetchGitRemotesDetailed.mockResolvedValue([]);
|
||||
mockFetchProjects.mockResolvedValue([]);
|
||||
mockUseWorktrunkInstallStatus.mockReturnValue({
|
||||
status: "missing",
|
||||
requestInstall: vi.fn(),
|
||||
@@ -697,16 +703,23 @@ describe("SettingsModal", () => {
|
||||
renderModal({ initialSection: "global-general" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
const input = screen.getByLabelText("Global default tracking repo") as HTMLInputElement;
|
||||
expect(input.value).toBe("");
|
||||
const control = screen.getByRole("combobox", { name: "Global default tracking repo" }) as HTMLSelectElement;
|
||||
expect(control).toBeInTheDocument();
|
||||
expect(screen.getByText(/Projects inherit this value when they do not set a project default tracking repo/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("saves global default tracking repo via global settings payload only", async () => {
|
||||
mockFetchProjects.mockResolvedValueOnce([{ id: "p-1", name: "Alpha" }]);
|
||||
mockFetchGitRemotes.mockResolvedValueOnce([{ name: "origin", owner: "octo", repo: "global-default", url: "https://github.com/octo/global-default.git" }]);
|
||||
|
||||
renderModal({ initialSection: "global-general" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
await userEvent.type(screen.getByLabelText("Global default tracking repo"), "octo/global-default");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("combobox", { name: "Global default tracking repo" })).toHaveValue("__custom__");
|
||||
});
|
||||
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Global default tracking repo" }), "octo/global-default");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -721,6 +734,16 @@ describe("SettingsModal", () => {
|
||||
expect(projectPayload.githubTrackingDefaultRepo).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows global tracking repo error hint and keeps custom entry when lookups fail", async () => {
|
||||
mockFetchProjects.mockRejectedValueOnce(new Error("no projects"));
|
||||
|
||||
renderModal({ initialSection: "global-general" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
expect(await screen.findByText(/Could not load project list/i)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("owner/repo")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders and saves agent provisioning approval settings", async () => {
|
||||
@@ -917,12 +940,12 @@ describe("SettingsModal", () => {
|
||||
expect(screen.getByRole("heading", { name: "GitHub Tracking" })).toBeInTheDocument();
|
||||
|
||||
const modeSelect = screen.getByLabelText("Default tracking mode for new tasks") as HTMLSelectElement;
|
||||
const repoInput = screen.getByLabelText("Project default tracking repo") as HTMLInputElement;
|
||||
const repoSelect = screen.getByRole("combobox", { name: "Project default tracking repo" }) as HTMLSelectElement;
|
||||
expect(modeSelect.value).toBe("off");
|
||||
expect(repoInput.value).toBe("");
|
||||
expect(repoSelect.value).toBe("__custom__");
|
||||
|
||||
await userEvent.selectOptions(modeSelect, "new-tasks");
|
||||
await userEvent.type(repoInput, "octo/repo");
|
||||
await userEvent.type(screen.getByPlaceholderText("owner/repo"), "octo/repo");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -950,13 +973,13 @@ describe("SettingsModal", () => {
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
const modeSelect = screen.getByLabelText("Default tracking mode for new tasks") as HTMLSelectElement;
|
||||
const repoInput = screen.getByLabelText("Project default tracking repo") as HTMLInputElement;
|
||||
const repoSelect = screen.getByRole("combobox", { name: "Project default tracking repo" }) as HTMLSelectElement;
|
||||
|
||||
expect(modeSelect.value).toBe("new-tasks");
|
||||
expect(repoInput.value).toBe("octo/existing");
|
||||
expect(repoSelect.value).toBe("__custom__");
|
||||
|
||||
await userEvent.selectOptions(modeSelect, "off");
|
||||
await userEvent.clear(repoInput);
|
||||
await userEvent.clear(screen.getByPlaceholderText("owner/repo"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -991,6 +1014,40 @@ describe("SettingsModal", () => {
|
||||
expect(screen.getByText("Title, commit message, and GitHub tracking issue summarization model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("picks a project repo suggestion and preserves label association", async () => {
|
||||
mockFetchGitRemotes.mockResolvedValueOnce([
|
||||
{ name: "origin", owner: "octo", repo: "repo", url: "https://github.com/octo/repo.git" },
|
||||
]);
|
||||
|
||||
renderModal({ initialSection: "general" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
const repoSelect = screen.getByRole("combobox", { name: "Project default tracking repo" }) as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
expect(within(repoSelect).getByRole("option", { name: "octo/repo" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.selectOptions(repoSelect, "octo/repo");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSettings).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const payload = mockUpdateSettings.mock.calls[0][0] as Record<string, unknown>;
|
||||
expect(payload.githubTrackingDefaultRepo).toBe("octo/repo");
|
||||
});
|
||||
|
||||
it("shows project tracking repo error hint and keeps custom entry when remotes fail", async () => {
|
||||
mockFetchGitRemotes.mockRejectedValueOnce(new Error("remotes failed"));
|
||||
|
||||
renderModal({ initialSection: "general" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
expect(await screen.findByText(/Could not load detected remotes/i)).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("owner/repo")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("always shows GitHub tracking summarization helper copy", async () => {
|
||||
renderModal({ initialSection: "general" });
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import React from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TrackingRepoSelect } from "../TrackingRepoSelect";
|
||||
|
||||
describe("TrackingRepoSelect", () => {
|
||||
it("renders suggestions and selected option", () => {
|
||||
render(
|
||||
<TrackingRepoSelect
|
||||
id="tracking"
|
||||
value="octo/widgets"
|
||||
onChange={vi.fn()}
|
||||
options={[
|
||||
{ value: "octo/widgets", label: "octo/widgets" },
|
||||
{ value: "acme/cli", label: "acme/cli" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = screen.getByRole("combobox");
|
||||
expect(select).toHaveValue("octo/widgets");
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("fires onChange when selecting suggestion", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<TrackingRepoSelect
|
||||
id="tracking"
|
||||
value=""
|
||||
onChange={onChange}
|
||||
options={[
|
||||
{ value: "octo/widgets", label: "octo/widgets" },
|
||||
{ value: "acme/cli", label: "acme/cli" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.selectOptions(screen.getByRole("combobox"), "acme/cli");
|
||||
expect(onChange).toHaveBeenCalledWith("acme/cli");
|
||||
});
|
||||
|
||||
it("shows custom input and edits custom value", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
|
||||
function Harness() {
|
||||
const [value, setValue] = React.useState("");
|
||||
return (
|
||||
<TrackingRepoSelect
|
||||
id="tracking"
|
||||
value={value}
|
||||
onChange={(next) => {
|
||||
setValue(next);
|
||||
onChange(next);
|
||||
}}
|
||||
options={[{ value: "octo/widgets", label: "octo/widgets" }]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
|
||||
await user.selectOptions(screen.getByRole("combobox"), "__custom__");
|
||||
const input = screen.getByRole("textbox");
|
||||
await user.type(input, "custom/repo");
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith("custom/repo");
|
||||
});
|
||||
|
||||
it("treats non-suggestion value as custom", () => {
|
||||
render(
|
||||
<TrackingRepoSelect
|
||||
id="tracking"
|
||||
value="other/repo"
|
||||
onChange={vi.fn()}
|
||||
options={[{ value: "octo/widgets", label: "octo/widgets" }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveValue("__custom__");
|
||||
expect(screen.getByRole("textbox")).toHaveValue("other/repo");
|
||||
});
|
||||
|
||||
it("deduplicates duplicate suggestions", () => {
|
||||
render(
|
||||
<TrackingRepoSelect
|
||||
id="tracking"
|
||||
value=""
|
||||
onChange={vi.fn()}
|
||||
options={[
|
||||
{ value: "octo/widgets", label: "octo/widgets", source: "A" },
|
||||
{ value: "octo/widgets", label: "octo/widgets", source: "B" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const options = screen.getAllByRole("option");
|
||||
expect(options.filter((option) => option.getAttribute("value") === "octo/widgets")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders loading/error hints without disabling custom entry", () => {
|
||||
const { rerender } = render(
|
||||
<TrackingRepoSelect
|
||||
id="tracking"
|
||||
value=""
|
||||
onChange={vi.fn()}
|
||||
options={[]}
|
||||
loading
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Loading detected GitHub remotes…")).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<TrackingRepoSelect
|
||||
id="tracking"
|
||||
value=""
|
||||
onChange={vi.fn()}
|
||||
options={[]}
|
||||
error="Unable to load remotes"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Unable to load remotes")).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -11,7 +11,7 @@ const qualityAppTests = [
|
||||
"app/api/**/*.test.ts",
|
||||
// Representative workflow/component coverage. Exhaustive modal/view suites
|
||||
// stay available in the full `dashboard-app` project.
|
||||
"app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile-view-switch,ChatView,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,ReliabilityView,ResearchView,SettingsModal.worktrunk,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskChangesTab,TaskComments,TaskDetailModal.github-tracking-header,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,ThemeSelectorSwatchContract,WorktrunkInstallApprovalDetails,WorkflowResultsTab}.test.tsx",
|
||||
"app/components/__tests__/{ActiveAgentsPanel,AgentMentionPopup,AgentMetricsBar,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile-view-switch,ChatView,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DirectoryPicker,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,InlineCreateCard,LoginInstructions,MemoryView,MessageComposer,MobileNavBar,NewTaskModal,NodeCard,NodeHealthDot,NodeStatusIndicator,ProjectCard,ProjectSelector,ProviderIcon,QuickChatFAB,ReliabilityView,ResearchView,SettingsModal,SettingsModal.worktrunk,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskChangesTab,TaskComments,TaskDetailModal.github-tracking-header,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,ThemeSelectorSwatchContract,TrackingRepoSelect,WorktrunkInstallApprovalDetails,WorkflowResultsTab}.test.tsx",
|
||||
// Hooks and utilities are fast, user-visible state/formatting behavior.
|
||||
"app/context/**/*.test.tsx",
|
||||
"app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodeSettingsSync,useProjects,useQuickChat,useTasks,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",
|
||||
|
||||
Reference in New Issue
Block a user