FN-5932: fix research settings containment layout
Keep research settings controls inside their containment wrappers so inputs stay onscreen across breakpoints. - wrap advanced provider controls in a dedicated research settings body container - tighten research grid and field min-width behavior in desktop and mobile styles - add regression coverage for global and project research sections on desktop and mobile Files changed: packages/dashboard/app/components/SettingsModal.css | 37 +++++++- packages/dashboard/app/components/SettingsModal.tsx | 104 +++++++++++---------- packages/dashboard/app/components/__tests__/SettingsModal.test.tsx | 65 +++++++++++++ packages/dashboard/app/components/__tests__/settings-mobile.test.tsx | 48 ++++++++++ 4 files changed, 201 insertions(+), 53 deletions(-) Fusion-Task-Id: FN-5932 Fusion-Task-Lineage: 22dff41f-b563-40d0-97ea-1ca6d1e09466
This commit is contained in:
@@ -439,18 +439,29 @@
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-research-source-grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-research-limits-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-research-limits-grid > * {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-research-limit-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-research-limits-error {
|
||||
@@ -1002,10 +1013,31 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-research-provider-advanced-details {
|
||||
margin-left: var(--space-md);
|
||||
min-width: 0;
|
||||
padding-inline-start: var(--space-md);
|
||||
}
|
||||
|
||||
.settings-research-provider-advanced-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
padding-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.settings-research-provider-advanced-body > .form-group {
|
||||
margin-top: 0;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-research-provider-advanced-body .input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-research-empty-state {
|
||||
@@ -1013,11 +1045,12 @@
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.settings-research-provider-advanced-details {
|
||||
margin-left: 0;
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5615,57 +5615,59 @@ export function SettingsModal({
|
||||
</small>
|
||||
<details className="settings-option-details settings-research-provider-advanced-details">
|
||||
<summary>Advanced — external search providers</summary>
|
||||
<div className="form-group">
|
||||
<label htmlFor="research-global-search-provider-advanced">Search Provider</label>
|
||||
<select
|
||||
id="research-global-search-provider-advanced"
|
||||
className="input"
|
||||
value={externalProvider ? resolvedProvider : "searxng"}
|
||||
onChange={(event) =>
|
||||
setSearchProvider(event.target.value as Settings["researchGlobalWebSearchProvider"])
|
||||
}
|
||||
>
|
||||
<option value="searxng">SearXNG</option>
|
||||
<option value="brave">Brave</option>
|
||||
<option value="google">Google Custom Search</option>
|
||||
<option value="tavily">Tavily</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="research-global-searxng-url">SearXNG URL</label>
|
||||
<input
|
||||
id="research-global-searxng-url"
|
||||
className="input"
|
||||
value={form.researchGlobalSearxngUrl ?? ""}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
researchGlobalSearxngUrl: event.target.value || undefined,
|
||||
}))
|
||||
}
|
||||
placeholder="https://searx.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="research-global-google-cx">Google Search CX</label>
|
||||
<input
|
||||
id="research-global-google-cx"
|
||||
className="input"
|
||||
value={form.researchGlobalGoogleSearchCx ?? ""}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
researchGlobalGoogleSearchCx: event.target.value || undefined,
|
||||
}))
|
||||
}
|
||||
placeholder="custom-search-engine-id"
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-empty-state settings-research-empty-state" role="note">
|
||||
Configure Brave, Tavily, and Google API keys in Authentication.
|
||||
<button type="button" className="btn btn-sm" onClick={() => setActiveSection("authentication")}>
|
||||
Open Authentication Settings
|
||||
</button>
|
||||
<div className="settings-research-provider-advanced-body">
|
||||
<div className="form-group">
|
||||
<label htmlFor="research-global-search-provider-advanced">Search Provider</label>
|
||||
<select
|
||||
id="research-global-search-provider-advanced"
|
||||
className="input"
|
||||
value={externalProvider ? resolvedProvider : "searxng"}
|
||||
onChange={(event) =>
|
||||
setSearchProvider(event.target.value as Settings["researchGlobalWebSearchProvider"])
|
||||
}
|
||||
>
|
||||
<option value="searxng">SearXNG</option>
|
||||
<option value="brave">Brave</option>
|
||||
<option value="google">Google Custom Search</option>
|
||||
<option value="tavily">Tavily</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="research-global-searxng-url">SearXNG URL</label>
|
||||
<input
|
||||
id="research-global-searxng-url"
|
||||
className="input"
|
||||
value={form.researchGlobalSearxngUrl ?? ""}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
researchGlobalSearxngUrl: event.target.value || undefined,
|
||||
}))
|
||||
}
|
||||
placeholder="https://searx.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="research-global-google-cx">Google Search CX</label>
|
||||
<input
|
||||
id="research-global-google-cx"
|
||||
className="input"
|
||||
value={form.researchGlobalGoogleSearchCx ?? ""}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
researchGlobalGoogleSearchCx: event.target.value || undefined,
|
||||
}))
|
||||
}
|
||||
placeholder="custom-search-engine-id"
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-empty-state settings-research-empty-state" role="note">
|
||||
Configure Brave, Tavily, and Google API keys in Authentication.
|
||||
<button type="button" className="btn btn-sm" onClick={() => setActiveSection("authentication")}>
|
||||
Open Authentication Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -4793,6 +4793,13 @@ describe("SettingsModal", () => {
|
||||
expect(details).toHaveAttribute("open");
|
||||
expect(await screen.findByLabelText("SearXNG URL")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Open Authentication Settings/i)).toBeInTheDocument();
|
||||
|
||||
const advancedBody = details?.querySelector(".settings-research-provider-advanced-body");
|
||||
expect(advancedBody).toBeTruthy();
|
||||
expect(advancedBody?.querySelectorAll(".form-group")).toHaveLength(3);
|
||||
expect(screen.getByLabelText("Search Provider")).toHaveClass("input");
|
||||
expect(screen.getByLabelText("SearXNG URL")).toHaveClass("input");
|
||||
expect(screen.getByLabelText("Google Search CX")).toHaveClass("input");
|
||||
});
|
||||
|
||||
it("keeps default max sources outside advanced details and groups provider controls", async () => {
|
||||
@@ -4813,6 +4820,54 @@ describe("SettingsModal", () => {
|
||||
expect(providerGroup).toContainElement(screen.getByText(/No API key required\./i));
|
||||
});
|
||||
|
||||
it("keeps research limits and source controls inside desktop containment grids for both sections", async () => {
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await openResearchGlobalSection();
|
||||
|
||||
const defaultMaxConcurrent = screen.getByLabelText("Default Max Concurrent Runs");
|
||||
const defaultMaxSources = screen.getByLabelText("Default Max Sources Per Run");
|
||||
const defaultMaxDuration = screen.getByLabelText("Default Max Duration (ms)");
|
||||
const defaultRequestTimeout = screen.getByLabelText("Request Timeout (ms)");
|
||||
|
||||
const globalLimitsGrid = defaultMaxConcurrent.closest(".settings-research-limits-grid");
|
||||
expect(globalLimitsGrid).toBeTruthy();
|
||||
expect(defaultMaxSources.closest(".settings-research-limits-grid")).toBe(globalLimitsGrid);
|
||||
expect(defaultMaxDuration.closest(".settings-research-limits-grid")).toBe(globalLimitsGrid);
|
||||
expect(defaultRequestTimeout.closest(".settings-research-limits-grid")).toBe(globalLimitsGrid);
|
||||
expect(defaultMaxConcurrent).toHaveClass("input");
|
||||
expect(defaultMaxSources).toHaveClass("input");
|
||||
expect(defaultMaxDuration).toHaveClass("input");
|
||||
expect(defaultRequestTimeout).toHaveClass("input");
|
||||
|
||||
const globalSourceGrid = screen.getByRole("checkbox", { name: "GitHub" }).closest(".settings-research-source-grid");
|
||||
expect(globalSourceGrid).toBeTruthy();
|
||||
expect(screen.getByRole("checkbox", { name: "Local Docs" }).closest(".settings-research-source-grid")).toBe(globalSourceGrid);
|
||||
|
||||
await openResearchProjectSection();
|
||||
|
||||
const projectMaxConcurrent = screen.getByLabelText("Max Concurrent Runs");
|
||||
const projectMaxSources = screen.getByLabelText("Max Sources Per Run");
|
||||
const projectMaxDuration = screen.getByLabelText("Max Duration (ms)");
|
||||
const projectRequestTimeout = screen.getByLabelText("Request Timeout (ms)");
|
||||
|
||||
const projectLimitsGrid = projectMaxConcurrent.closest(".settings-research-limits-grid");
|
||||
expect(projectLimitsGrid).toBeTruthy();
|
||||
expect(projectMaxSources.closest(".settings-research-limits-grid")).toBe(projectLimitsGrid);
|
||||
expect(projectMaxDuration.closest(".settings-research-limits-grid")).toBe(projectLimitsGrid);
|
||||
expect(projectRequestTimeout.closest(".settings-research-limits-grid")).toBe(projectLimitsGrid);
|
||||
expect(projectMaxConcurrent).toHaveClass("input");
|
||||
expect(projectMaxSources).toHaveClass("input");
|
||||
expect(projectMaxDuration).toHaveClass("input");
|
||||
expect(projectRequestTimeout).toHaveClass("input");
|
||||
|
||||
const projectSourceGrid = screen.getByRole("checkbox", { name: "Page Fetch" }).closest(".settings-research-source-grid");
|
||||
expect(projectSourceGrid).toBeTruthy();
|
||||
expect(screen.getByRole("checkbox", { name: "GitHub" }).closest(".settings-research-source-grid")).toBe(projectSourceGrid);
|
||||
expect(screen.getByRole("checkbox", { name: "Local Docs" }).closest(".settings-research-source-grid")).toBe(projectSourceGrid);
|
||||
expect(screen.getByRole("checkbox", { name: "LLM Synthesis" }).closest(".settings-research-source-grid")).toBe(projectSourceGrid);
|
||||
});
|
||||
|
||||
it("groups project limits fields in one grid and keeps validation error visible", async () => {
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
@@ -4828,6 +4883,16 @@ describe("SettingsModal", () => {
|
||||
expect(maxSources.closest(".settings-research-limits-grid")).toBe(limitsGrid);
|
||||
expect(maxDuration.closest(".settings-research-limits-grid")).toBe(limitsGrid);
|
||||
expect(requestTimeout.closest(".settings-research-limits-grid")).toBe(limitsGrid);
|
||||
expect(maxConcurrent).toHaveClass("input");
|
||||
expect(maxSources).toHaveClass("input");
|
||||
expect(maxDuration).toHaveClass("input");
|
||||
expect(requestTimeout).toHaveClass("input");
|
||||
|
||||
const sourceGrid = screen.getByRole("checkbox", { name: "Page Fetch" }).closest(".settings-research-source-grid");
|
||||
expect(sourceGrid).toBeTruthy();
|
||||
expect(screen.getByRole("checkbox", { name: "GitHub" }).closest(".settings-research-source-grid")).toBe(sourceGrid);
|
||||
expect(screen.getByRole("checkbox", { name: "Local Docs" }).closest(".settings-research-source-grid")).toBe(sourceGrid);
|
||||
expect(screen.getByRole("checkbox", { name: "LLM Synthesis" }).closest(".settings-research-source-grid")).toBe(sourceGrid);
|
||||
|
||||
fireEvent.change(maxConcurrent, { target: { value: "0" } });
|
||||
await userEvent.click(screen.getByText("Save"));
|
||||
|
||||
@@ -261,6 +261,48 @@ describe("SettingsModal mobile adaptations", () => {
|
||||
expect(getByLabelText("Memory File")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps research settings controls inside mobile containment wrappers", async () => {
|
||||
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
||||
...defaultSettings,
|
||||
experimentalFeatures: { researchView: true },
|
||||
});
|
||||
|
||||
mockSettingsViewport(true);
|
||||
const user = userEvent.setup();
|
||||
const { getByLabelText } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
const picker = getByLabelText("Settings Section");
|
||||
await user.selectOptions(picker, "research-global");
|
||||
|
||||
const details = await within(document.body).findByText(/Advanced — external search providers/i);
|
||||
await user.click(details);
|
||||
|
||||
const advancedPanel = document.querySelector(".settings-research-provider-advanced-body");
|
||||
expect(advancedPanel).toBeTruthy();
|
||||
expect(document.querySelector(".settings-research-provider-advanced-details")).toBeTruthy();
|
||||
expect(document.querySelector(".settings-research-limits-grid")).toBeTruthy();
|
||||
expect(document.querySelector(".settings-research-source-grid")).toBeTruthy();
|
||||
|
||||
await user.selectOptions(picker, "research-project");
|
||||
|
||||
const maxConcurrent = await within(document.body).findByLabelText("Max Concurrent Runs");
|
||||
expect(maxConcurrent).toHaveClass("input");
|
||||
expect(document.querySelectorAll(".settings-research-limit-field").length).toBeGreaterThan(0);
|
||||
|
||||
const projectLimitsGrid = maxConcurrent.closest(".settings-research-limits-grid");
|
||||
expect(projectLimitsGrid).toBeTruthy();
|
||||
expect(within(document.body).getByLabelText("Max Sources Per Run").closest(".settings-research-limits-grid")).toBe(projectLimitsGrid);
|
||||
expect(within(document.body).getByLabelText("Max Duration (ms)").closest(".settings-research-limits-grid")).toBe(projectLimitsGrid);
|
||||
expect(within(document.body).getByLabelText("Request Timeout (ms)").closest(".settings-research-limits-grid")).toBe(projectLimitsGrid);
|
||||
|
||||
const projectSourceGrid = within(document.body).getByRole("checkbox", { name: "Page Fetch" }).closest(".settings-research-source-grid");
|
||||
expect(projectSourceGrid).toBeTruthy();
|
||||
expect(within(document.body).getByRole("checkbox", { name: "GitHub" }).closest(".settings-research-source-grid")).toBe(projectSourceGrid);
|
||||
expect(within(document.body).getByRole("checkbox", { name: "Local Docs" }).closest(".settings-research-source-grid")).toBe(projectSourceGrid);
|
||||
expect(within(document.body).getByRole("checkbox", { name: "LLM Synthesis" }).closest(".settings-research-source-grid")).toBe(projectSourceGrid);
|
||||
});
|
||||
|
||||
it("renders settings nav items with active class for touch styling", async () => {
|
||||
const { container } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
@@ -354,10 +396,16 @@ describe("SettingsModal mobile adaptations", () => {
|
||||
// Remote Access header elements must use the same mobile gutter as other remote blocks
|
||||
expectMobileRule(css, ".remote-status-bar", "margin: 0 var(--space-lg) var(--space-md);");
|
||||
expectMobileRule(css, ".remote-share-block", "margin: 0 var(--space-lg) var(--space-md);");
|
||||
expectMobileRule(css, ".settings-research-provider-advanced-details", "padding-inline-start: 0;");
|
||||
expectMobileRule(css, ".settings-research-source-grid", "grid-template-columns: 1fr;");
|
||||
expectMobileRule(css, ".settings-research-limits-grid", "grid-template-columns: 1fr;");
|
||||
|
||||
// Base rules: desktop uses --space-xl horizontal margin for remote header elements
|
||||
expectBaseRule(css, ".remote-status-bar", "margin: 0 var(--space-xl) var(--space-md);");
|
||||
expectBaseRule(css, ".remote-share-block", "margin: 0 var(--space-xl) var(--space-md);");
|
||||
expectBaseRule(css, ".settings-research-provider-advanced-details", "padding-inline-start: var(--space-md);");
|
||||
expectBaseRule(css, ".settings-research-provider-advanced-body > .form-group", "padding: 0;");
|
||||
expectBaseRule(css, ".settings-research-limits-grid", "min-width: 0;");
|
||||
|
||||
// Settings header actions keep compact controls on a shared height contract on desktop; mobile inherits this height (FN-4354 reverted prior mobile inflation).
|
||||
expectBaseRule(css, ".settings-header-actions", "--settings-header-action-height: calc(var(--space-md) * 2 + var(--space-xs) / 2);");
|
||||
|
||||
Reference in New Issue
Block a user