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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user