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;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-research-source-grid > * {
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-research-limits-grid {
|
.settings-research-limits-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-research-limits-grid > * {
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-research-limit-field {
|
.settings-research-limit-field {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-xs);
|
gap: var(--space-xs);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-research-limits-error {
|
.settings-research-limits-error {
|
||||||
@@ -1002,10 +1013,31 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-research-provider-advanced-details {
|
.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 {
|
.settings-research-empty-state {
|
||||||
@@ -1013,11 +1045,12 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.settings-research-provider-advanced-details {
|
.settings-research-provider-advanced-details {
|
||||||
margin-left: 0;
|
padding-inline-start: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5615,57 +5615,59 @@ export function SettingsModal({
|
|||||||
</small>
|
</small>
|
||||||
<details className="settings-option-details settings-research-provider-advanced-details">
|
<details className="settings-option-details settings-research-provider-advanced-details">
|
||||||
<summary>Advanced — external search providers</summary>
|
<summary>Advanced — external search providers</summary>
|
||||||
<div className="form-group">
|
<div className="settings-research-provider-advanced-body">
|
||||||
<label htmlFor="research-global-search-provider-advanced">Search Provider</label>
|
<div className="form-group">
|
||||||
<select
|
<label htmlFor="research-global-search-provider-advanced">Search Provider</label>
|
||||||
id="research-global-search-provider-advanced"
|
<select
|
||||||
className="input"
|
id="research-global-search-provider-advanced"
|
||||||
value={externalProvider ? resolvedProvider : "searxng"}
|
className="input"
|
||||||
onChange={(event) =>
|
value={externalProvider ? resolvedProvider : "searxng"}
|
||||||
setSearchProvider(event.target.value as Settings["researchGlobalWebSearchProvider"])
|
onChange={(event) =>
|
||||||
}
|
setSearchProvider(event.target.value as Settings["researchGlobalWebSearchProvider"])
|
||||||
>
|
}
|
||||||
<option value="searxng">SearXNG</option>
|
>
|
||||||
<option value="brave">Brave</option>
|
<option value="searxng">SearXNG</option>
|
||||||
<option value="google">Google Custom Search</option>
|
<option value="brave">Brave</option>
|
||||||
<option value="tavily">Tavily</option>
|
<option value="google">Google Custom Search</option>
|
||||||
</select>
|
<option value="tavily">Tavily</option>
|
||||||
</div>
|
</select>
|
||||||
<div className="form-group">
|
</div>
|
||||||
<label htmlFor="research-global-searxng-url">SearXNG URL</label>
|
<div className="form-group">
|
||||||
<input
|
<label htmlFor="research-global-searxng-url">SearXNG URL</label>
|
||||||
id="research-global-searxng-url"
|
<input
|
||||||
className="input"
|
id="research-global-searxng-url"
|
||||||
value={form.researchGlobalSearxngUrl ?? ""}
|
className="input"
|
||||||
onChange={(event) =>
|
value={form.researchGlobalSearxngUrl ?? ""}
|
||||||
setForm((current) => ({
|
onChange={(event) =>
|
||||||
...current,
|
setForm((current) => ({
|
||||||
researchGlobalSearxngUrl: event.target.value || undefined,
|
...current,
|
||||||
}))
|
researchGlobalSearxngUrl: event.target.value || undefined,
|
||||||
}
|
}))
|
||||||
placeholder="https://searx.example.com"
|
}
|
||||||
/>
|
placeholder="https://searx.example.com"
|
||||||
</div>
|
/>
|
||||||
<div className="form-group">
|
</div>
|
||||||
<label htmlFor="research-global-google-cx">Google Search CX</label>
|
<div className="form-group">
|
||||||
<input
|
<label htmlFor="research-global-google-cx">Google Search CX</label>
|
||||||
id="research-global-google-cx"
|
<input
|
||||||
className="input"
|
id="research-global-google-cx"
|
||||||
value={form.researchGlobalGoogleSearchCx ?? ""}
|
className="input"
|
||||||
onChange={(event) =>
|
value={form.researchGlobalGoogleSearchCx ?? ""}
|
||||||
setForm((current) => ({
|
onChange={(event) =>
|
||||||
...current,
|
setForm((current) => ({
|
||||||
researchGlobalGoogleSearchCx: event.target.value || undefined,
|
...current,
|
||||||
}))
|
researchGlobalGoogleSearchCx: event.target.value || undefined,
|
||||||
}
|
}))
|
||||||
placeholder="custom-search-engine-id"
|
}
|
||||||
/>
|
placeholder="custom-search-engine-id"
|
||||||
</div>
|
/>
|
||||||
<div className="settings-empty-state settings-research-empty-state" role="note">
|
</div>
|
||||||
Configure Brave, Tavily, and Google API keys in Authentication.
|
<div className="settings-empty-state settings-research-empty-state" role="note">
|
||||||
<button type="button" className="btn btn-sm" onClick={() => setActiveSection("authentication")}>
|
Configure Brave, Tavily, and Google API keys in Authentication.
|
||||||
Open Authentication Settings
|
<button type="button" className="btn btn-sm" onClick={() => setActiveSection("authentication")}>
|
||||||
</button>
|
Open Authentication Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4793,6 +4793,13 @@ describe("SettingsModal", () => {
|
|||||||
expect(details).toHaveAttribute("open");
|
expect(details).toHaveAttribute("open");
|
||||||
expect(await screen.findByLabelText("SearXNG URL")).toBeInTheDocument();
|
expect(await screen.findByLabelText("SearXNG URL")).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Open Authentication Settings/i)).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 () => {
|
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));
|
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 () => {
|
it("groups project limits fields in one grid and keeps validation error visible", async () => {
|
||||||
renderModal();
|
renderModal();
|
||||||
await waitForSettingsModalReady();
|
await waitForSettingsModalReady();
|
||||||
@@ -4828,6 +4883,16 @@ describe("SettingsModal", () => {
|
|||||||
expect(maxSources.closest(".settings-research-limits-grid")).toBe(limitsGrid);
|
expect(maxSources.closest(".settings-research-limits-grid")).toBe(limitsGrid);
|
||||||
expect(maxDuration.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(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" } });
|
fireEvent.change(maxConcurrent, { target: { value: "0" } });
|
||||||
await userEvent.click(screen.getByText("Save"));
|
await userEvent.click(screen.getByText("Save"));
|
||||||
|
|||||||
@@ -261,6 +261,48 @@ describe("SettingsModal mobile adaptations", () => {
|
|||||||
expect(getByLabelText("Memory File")).toBeTruthy();
|
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 () => {
|
it("renders settings nav items with active class for touch styling", async () => {
|
||||||
const { container } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
|
const { container } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
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
|
// 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-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, ".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
|
// 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-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, ".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).
|
// 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);");
|
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