mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
fix(ui): restore skill downloads and search paging
This commit is contained in:
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const navigateMock = vi.fn();
|
||||
let searchMock: { q?: string; type?: "all" | "skills" | "plugins" } = {};
|
||||
const useUnifiedSearchMock = vi.fn();
|
||||
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
createFileRoute: () => (config: { component?: unknown; validateSearch?: unknown }) => ({
|
||||
@@ -16,14 +17,7 @@ vi.mock("@tanstack/react-router", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../lib/useUnifiedSearch", () => ({
|
||||
useUnifiedSearch: () => ({
|
||||
results: [],
|
||||
skillResults: [],
|
||||
pluginResults: [],
|
||||
skillCount: 0,
|
||||
pluginCount: 0,
|
||||
isSearching: false,
|
||||
}),
|
||||
useUnifiedSearch: (...args: unknown[]) => useUnifiedSearchMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../components/PluginListItem", () => ({
|
||||
@@ -52,6 +46,15 @@ describe("search route", () => {
|
||||
beforeEach(() => {
|
||||
searchMock = { q: "first" };
|
||||
navigateMock.mockReset();
|
||||
useUnifiedSearchMock.mockReset();
|
||||
useUnifiedSearchMock.mockReturnValue({
|
||||
results: [],
|
||||
skillResults: [],
|
||||
pluginResults: [],
|
||||
skillCount: 0,
|
||||
pluginCount: 0,
|
||||
isSearching: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the input synced with query param changes while mounted", async () => {
|
||||
@@ -81,4 +84,43 @@ describe("search route", () => {
|
||||
|
||||
expect(screen.queryByRole("button", { name: /users/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("can request more results from global search", async () => {
|
||||
searchMock = { q: "weather", type: "skills" };
|
||||
useUnifiedSearchMock.mockReturnValue({
|
||||
results: Array.from({ length: 25 }, (_, index) => ({
|
||||
type: "skill",
|
||||
skill: {
|
||||
_id: `skill-${index}`,
|
||||
slug: `weather-${index}`,
|
||||
displayName: `Weather ${index}`,
|
||||
ownerUserId: "users:1",
|
||||
stats: { downloads: 0, stars: 0 },
|
||||
updatedAt: 1,
|
||||
createdAt: 1,
|
||||
},
|
||||
ownerHandle: "clawhub",
|
||||
score: 1,
|
||||
})),
|
||||
skillResults: [],
|
||||
pluginResults: [],
|
||||
skillCount: 25,
|
||||
pluginCount: 0,
|
||||
isSearching: false,
|
||||
});
|
||||
const route = await loadRoute();
|
||||
const Component = route.__config.component as ComponentType;
|
||||
|
||||
render(<Component />);
|
||||
|
||||
expect(useUnifiedSearchMock).toHaveBeenLastCalledWith("weather", "skills", {
|
||||
limits: { skills: 25, plugins: 25 },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
|
||||
expect(useUnifiedSearchMock).toHaveBeenLastCalledWith("weather", "skills", {
|
||||
limits: { skills: 50, plugins: 50 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Doc, Id } from "../../convex/_generated/dataModel";
|
||||
import { getSkillBadges } from "../lib/badges";
|
||||
import { formatCompactStat, formatSkillStatsTriplet } from "../lib/numberFormat";
|
||||
import type { PublicPublisher, PublicSkill } from "../lib/publicUser";
|
||||
import { getRuntimeEnv } from "../lib/runtimeEnv";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { DetailHero } from "./DetailPageShell";
|
||||
import { SkillInstallCard } from "./SkillInstallCard";
|
||||
@@ -107,6 +108,13 @@ export function SkillHeader({
|
||||
}: SkillHeaderProps) {
|
||||
const formattedStats = formatSkillStatsTriplet(skill.stats);
|
||||
const installOwnerId = owner?._id ?? skill.ownerPublisherId ?? skill.ownerUserId ?? null;
|
||||
const convexSiteUrl = getRuntimeEnv("VITE_CONVEX_SITE_URL") ?? "https://clawhub.ai";
|
||||
const downloadHref =
|
||||
latestVersion && !nixPlugin
|
||||
? `${convexSiteUrl}/api/v1/download?slug=${encodeURIComponent(skill.slug)}`
|
||||
: null;
|
||||
const hasTitleActions =
|
||||
Boolean(downloadHref) || isAuthenticated || canManage || isStaff || Boolean(settingsHref);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -171,8 +179,16 @@ export function SkillHeader({
|
||||
<span className="plugin-version-badge">v{latestVersion.version}</span>
|
||||
) : null}
|
||||
{nixPlugin ? <Badge variant="accent">Plugin bundle (nix)</Badge> : null}
|
||||
{isAuthenticated || canManage || isStaff || settingsHref ? (
|
||||
{hasTitleActions ? (
|
||||
<div className="skill-title-actions">
|
||||
{downloadHref ? (
|
||||
<Button asChild variant="outline" size="sm" className="skill-settings-link">
|
||||
<a href={downloadHref}>
|
||||
<Download size={14} aria-hidden="true" />
|
||||
Download zip
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<button
|
||||
|
||||
+41
-9
@@ -12,6 +12,8 @@ import {
|
||||
type UnifiedSkillResult,
|
||||
} from "../lib/useUnifiedSearch";
|
||||
|
||||
const SEARCH_PAGE_SIZE = 25;
|
||||
|
||||
type SearchState = {
|
||||
q?: string;
|
||||
type?: UnifiedSearchType;
|
||||
@@ -30,15 +32,32 @@ function UnifiedSearchPage() {
|
||||
const navigate = useNavigate();
|
||||
const activeType = search.type ?? "all";
|
||||
const [query, setQuery] = useState(search.q ?? "");
|
||||
const [resultLimit, setResultLimit] = useState(SEARCH_PAGE_SIZE);
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(search.q ?? "");
|
||||
}, [search.q]);
|
||||
|
||||
useEffect(() => {
|
||||
setResultLimit(SEARCH_PAGE_SIZE);
|
||||
}, [search.q, activeType]);
|
||||
|
||||
const { results, skillCount, pluginCount, isSearching } = useUnifiedSearch(
|
||||
search.q ?? "",
|
||||
activeType,
|
||||
{
|
||||
limits: {
|
||||
skills: resultLimit,
|
||||
plugins: resultLimit,
|
||||
},
|
||||
},
|
||||
);
|
||||
const canLoadMore =
|
||||
search.q &&
|
||||
!isSearching &&
|
||||
((activeType === "all" && (skillCount >= resultLimit || pluginCount >= resultLimit)) ||
|
||||
(activeType === "skills" && skillCount >= resultLimit) ||
|
||||
(activeType === "plugins" && pluginCount >= resultLimit));
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -121,15 +140,28 @@ function UnifiedSearchPage() {
|
||||
<p className="text-ink-soft">No results found for "{search.q}"</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="results-list">
|
||||
{results.map((item) =>
|
||||
item.type === "skill" ? (
|
||||
<SkillResultRow key={`skill-${item.skill._id}`} result={item} />
|
||||
) : (
|
||||
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<>
|
||||
<div className="results-list">
|
||||
{results.map((item) =>
|
||||
item.type === "skill" ? (
|
||||
<SkillResultRow key={`skill-${item.skill._id}`} result={item} />
|
||||
) : (
|
||||
<PluginResultRow key={`plugin-${item.plugin.name}`} result={item} />
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
{canLoadMore ? (
|
||||
<div className="search-load-more">
|
||||
<button
|
||||
type="button"
|
||||
className="search-load-more-button"
|
||||
onClick={() => setResultLimit((limit) => limit + SEARCH_PAGE_SIZE)}
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -8217,6 +8217,32 @@ code {
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.search-load-more {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.search-load-more-button {
|
||||
min-height: 38px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
padding: 0 var(--space-4);
|
||||
transition:
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.search-load-more-button:hover {
|
||||
border-color: color-mix(in srgb, var(--ink) 18%, var(--line));
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.search-page-form {
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user