fix(FN-1907): wrap skills page sections in scrollable content container

- Refactor SkillsView to wrap sections in .skills-view-content wrapper
- Add scrollable overflow handling for content container
- Add mobile responsiveness tests for skills view component
- Ensure consistent scrolling behavior across page sections
This commit is contained in:
Fusion
2026-04-16 07:21:17 -07:00
committed by gsxdsm
parent 9e1b81227e
commit c8f42856f5
2 changed files with 166 additions and 107 deletions

View File

@@ -150,115 +150,118 @@ export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
</div>
</div>
{/* Discovered Skills Section */}
<section className="skills-view-section" aria-labelledby="discovered-skills-title">
<h3 id="discovered-skills-title" className="skills-view-section-title">
Discovered Skills
</h3>
{/* Scrollable content area */}
<div className="skills-view-content">
{/* Discovered Skills Section */}
<section className="skills-view-section" aria-labelledby="discovered-skills-title">
<h3 id="discovered-skills-title" className="skills-view-section-title">
Discovered Skills
</h3>
{isLoadingDiscovered ? (
<div className="skills-view-loading">
<span className="spinner" />
Loading discovered skills...
</div>
) : discoveredSkills.length === 0 ? (
<div className="skills-view-empty">
<p>No skills discovered in this project.</p>
</div>
) : (
<div className="skills-view-list">
{discoveredSkills.map((skill) => (
<div key={skill.id} className="skills-view-item">
<div className="skills-view-item-info">
<span className="skills-view-item-name">{skill.name}</span>
<span className="skills-view-item-path">{skill.relativePath}</span>
<span className="skills-view-item-source">{skill.metadata.source}</span>
</div>
<label className="skills-view-item-toggle">
<input
type="checkbox"
checked={skill.enabled}
disabled={skill.toggling}
onChange={() => void handleToggleSkill(skill.id, skill.enabled)}
aria-label={`${skill.enabled ? "Disable" : "Enable"} ${skill.name}`}
/>
<span className="skills-view-toggle-slider" />
</label>
</div>
))}
</div>
)}
</section>
{/* Catalog Section */}
<section className="skills-view-section" aria-labelledby="catalog-title">
<h3 id="catalog-title" className="skills-view-section-title">
Skills Catalog
</h3>
{/* Search */}
<div className="skills-view-search">
<input
type="text"
className="form-input"
placeholder="Search skills..."
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
aria-label="Search skills catalog"
/>
</div>
{/* Catalog Content */}
{catalogError ? (
<div className="skills-view-error">
<p>{catalogError}</p>
<button
className="btn btn-sm"
onClick={() => void loadCatalog(debouncedQuery)}
>
Try Again
</button>
</div>
) : isLoadingCatalog ? (
<div className="skills-view-loading">
<span className="spinner" />
Loading catalog...
</div>
) : catalogEntries.length === 0 ? (
<div className="skills-view-empty">
{searchQuery ? (
<p>No skills match your search.</p>
) : (
<p>No skills available in the catalog.</p>
)}
</div>
) : (
<div className="skills-view-grid">
{catalogEntries.map((entry) => (
<div key={entry.id} className="skills-view-card">
<h4 className="skills-view-card-title">{entry.name}</h4>
{entry.description && (
<p className="skills-view-card-description">{entry.description}</p>
)}
{entry.tags && entry.tags.length > 0 && (
<div className="skills-view-card-tags">
{entry.tags.map((tag) => (
<span key={tag} className="badge badge--sm">
{tag}
</span>
))}
{isLoadingDiscovered ? (
<div className="skills-view-loading">
<span className="spinner" />
Loading discovered skills...
</div>
) : discoveredSkills.length === 0 ? (
<div className="skills-view-empty">
<p>No skills discovered in this project.</p>
</div>
) : (
<div className="skills-view-list">
{discoveredSkills.map((skill) => (
<div key={skill.id} className="skills-view-item">
<div className="skills-view-item-info">
<span className="skills-view-item-name">{skill.name}</span>
<span className="skills-view-item-path">{skill.relativePath}</span>
<span className="skills-view-item-source">{skill.metadata.source}</span>
</div>
)}
{entry.installs !== undefined && (
<span className="skills-view-card-installs">
{entry.installs.toLocaleString()} installs
</span>
)}
</div>
))}
<label className="skills-view-item-toggle">
<input
type="checkbox"
checked={skill.enabled}
disabled={skill.toggling}
onChange={() => void handleToggleSkill(skill.id, skill.enabled)}
aria-label={`${skill.enabled ? "Disable" : "Enable"} ${skill.name}`}
/>
<span className="skills-view-toggle-slider" />
</label>
</div>
))}
</div>
)}
</section>
{/* Catalog Section */}
<section className="skills-view-section" aria-labelledby="catalog-title">
<h3 id="catalog-title" className="skills-view-section-title">
Skills Catalog
</h3>
{/* Search */}
<div className="skills-view-search">
<input
type="text"
className="form-input"
placeholder="Search skills..."
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
aria-label="Search skills catalog"
/>
</div>
)}
</section>
{/* Catalog Content */}
{catalogError ? (
<div className="skills-view-error">
<p>{catalogError}</p>
<button
className="btn btn-sm"
onClick={() => void loadCatalog(debouncedQuery)}
>
Try Again
</button>
</div>
) : isLoadingCatalog ? (
<div className="skills-view-loading">
<span className="spinner" />
Loading catalog...
</div>
) : catalogEntries.length === 0 ? (
<div className="skills-view-empty">
{searchQuery ? (
<p>No skills match your search.</p>
) : (
<p>No skills available in the catalog.</p>
)}
</div>
) : (
<div className="skills-view-grid">
{catalogEntries.map((entry) => (
<div key={entry.id} className="skills-view-card">
<h4 className="skills-view-card-title">{entry.name}</h4>
{entry.description && (
<p className="skills-view-card-description">{entry.description}</p>
)}
{entry.tags && entry.tags.length > 0 && (
<div className="skills-view-card-tags">
{entry.tags.map((tag) => (
<span key={tag} className="badge badge--sm">
{tag}
</span>
))}
</div>
)}
{entry.installs !== undefined && (
<span className="skills-view-card-installs">
{entry.installs.toLocaleString()} installs
</span>
)}
</div>
))}
</div>
)}
</section>
</div>
</div>
);
}

View File

@@ -1,7 +1,19 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { readFileSync } from "fs";
import { resolve } from "path";
// Mock API functions
const mockFetchDiscoveredSkills = vi.fn().mockResolvedValue([]);
const mockFetchSkillsCatalog = vi.fn().mockResolvedValue({ entries: [] });
const mockToggleExecutionSkill = vi.fn().mockResolvedValue(undefined);
vi.mock("../../../api", () => ({
fetchDiscoveredSkills: (...args: unknown[]) => mockFetchDiscoveredSkills(...args),
fetchSkillsCatalog: (...args: unknown[]) => mockFetchSkillsCatalog(...args),
toggleExecutionSkill: (...args: unknown[]) => mockToggleExecutionSkill(...args),
}));
function extractRuleBlock(css: string, selector: string): string {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`));
@@ -173,4 +185,48 @@ describe("skills-view mobile css", () => {
expect(cssContent).toContain(".skills-view-search {");
expect(cssContent).toContain(".skills-view-toggle-slider {");
});
it(".skills-view-content has overflow-y auto in base CSS", () => {
// Verify the base rule exists in the CSS file
// The base rule contains overflow-y: auto with flex: 1 and padding: 20px
expect(cssContent).toMatch(/\.skills-view-content\s*\{[^}]*overflow-y:\s*auto[^}]*\}/s);
expect(cssContent).toMatch(/\.skills-view-content\s*\{[^}]*flex:\s*1[^}]*\}/s);
expect(cssContent).toMatch(/\.skills-view-content\s*\{[^}]*padding:\s*20px[^}]*\}/s);
});
});
describe("SkillsView component structure", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
it("renders .skills-view-content wrapper around sections", async () => {
const { SkillsView } = await import("../SkillsView");
render(
<SkillsView
projectId="test-project"
addToast={vi.fn()}
onClose={vi.fn()}
/>
);
// The wrapper should exist
const contentWrapper = screen.getByTestId("skills-view").querySelector(".skills-view-content");
expect(contentWrapper).not.toBeNull();
// The two sections should be inside the wrapper
const sections = contentWrapper!.querySelectorAll(".skills-view-section");
expect(sections.length).toBe(2);
// Header should be outside the wrapper (directly on skills-view)
const skillsView = screen.getByTestId("skills-view");
const header = skillsView.querySelector(".skills-view-header");
expect(header).not.toBeNull();
expect(header!.parentElement).toBe(skillsView);
});
});