diff --git a/.gitignore b/.gitignore index 3798bd7b2..c527d77f6 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ coverage playwright-report test-results .playwright +.agents +/skills +skills-lock.json +.claude \ No newline at end of file diff --git a/convex/skills.dashboard.test.ts b/convex/skills.dashboard.test.ts new file mode 100644 index 000000000..421458084 --- /dev/null +++ b/convex/skills.dashboard.test.ts @@ -0,0 +1,183 @@ +import { getAuthUserId } from "@convex-dev/auth/server"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@convex-dev/auth/server", () => ({ + getAuthUserId: vi.fn(), + authTables: {}, +})); + +import { listDashboardPaginated } from "./skills"; + +type WrappedHandler = { + _handler: (ctx: unknown, args: TArgs) => Promise; +}; + +const handler = ( + listDashboardPaginated as unknown as WrappedHandler< + { + ownerUserId?: string; + ownerPublisherId?: string; + paginationOpts: { cursor: string | null; numItems: number }; + }, + { page: Array<{ slug: string }>; isDone: boolean; continueCursor: string } + > +)._handler; + +function makeSkill(slug: string, overrides: Record = {}) { + return { + _id: `skills:${slug}`, + _creationTime: 1, + slug, + displayName: slug.charAt(0).toUpperCase() + slug.slice(1), + summary: `${slug} integration.`, + ownerUserId: "users:owner", + ownerPublisherId: undefined, + canonicalSkillId: undefined, + forkOf: undefined, + latestVersionId: undefined, + tags: {}, + badges: undefined, + stats: { downloads: 0, installsCurrent: 0, installsAllTime: 0, stars: 0, versions: 1, comments: 0 }, + createdAt: 1, + updatedAt: 2, + softDeletedAt: undefined, + moderationStatus: "active", + moderationFlags: [], + moderationReason: undefined, + ...overrides, + }; +} + +function makeCtx(skills: ReturnType[]) { + return { + db: { + get: vi.fn(async (id: string) => { + if (id === "users:owner") { + return { _id: "users:owner", _creationTime: 1, handle: "owner", displayName: "Owner" }; + } + if (id === "publishers:pub") { + return { + _id: "publishers:pub", + _creationTime: 1, + kind: "user", + handle: "owner", + displayName: "Owner", + linkedUserId: "users:owner", + }; + } + return null; + }), + query: vi.fn((table: string) => { + if (table === "publisherMembers") { + return { + withIndex: vi.fn(() => ({ + unique: vi.fn().mockResolvedValue(null), + })), + }; + } + if (table === "skills") { + return { + withIndex: vi.fn(() => ({ + filter: vi.fn(() => ({ + order: vi.fn(() => ({ + paginate: vi.fn().mockResolvedValue({ + page: skills, + isDone: true, + continueCursor: "", + }), + })), + })), + })), + }; + } + if (table === "skillBadges") { + return { + withIndex: vi.fn(() => ({ + take: vi.fn().mockResolvedValue([]), + })), + }; + } + throw new Error(`unexpected table ${table}`); + }), + }, + }; +} + +const paginationOpts = { cursor: null, numItems: 50 }; + +describe("skills.listDashboardPaginated", () => { + it("returns paginated skills for ownerUserId", async () => { + vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never); + const skills = [makeSkill("slack"), makeSkill("stripe")]; + const ctx = makeCtx(skills); + + const result = await handler(ctx as never, { + ownerUserId: "users:owner", + paginationOpts, + } as never); + + expect(result.page).toHaveLength(2); + expect(result.page.map((s) => s.slug)).toEqual(["slack", "stripe"]); + expect(result.isDone).toBe(true); + }); + + it("returns paginated skills for ownerPublisherId", async () => { + vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never); + const skills = [makeSkill("github")]; + const ctx = makeCtx(skills); + + const result = await handler(ctx as never, { + ownerPublisherId: "publishers:pub", + paginationOpts, + } as never); + + expect(result.page).toEqual([expect.objectContaining({ slug: "github" })]); + }); + + it("returns empty when no owner specified", async () => { + vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never); + const ctx = makeCtx([makeSkill("a")]); + + const result = await handler(ctx as never, { paginationOpts } as never); + + expect(result.page).toEqual([]); + expect(result.isDone).toBe(true); + }); + + it("includes pending-review skills for own dashboard", async () => { + vi.mocked(getAuthUserId).mockResolvedValue("users:owner" as never); + const skills = [ + makeSkill("pending-skill", { + moderationStatus: "hidden", + moderationReason: "pending.scan", + }), + ]; + const ctx = makeCtx(skills); + + const result = await handler(ctx as never, { + ownerUserId: "users:owner", + paginationOpts, + } as never); + + expect(result.page).toHaveLength(1); + expect(result.page[0]).toHaveProperty("pendingReview", true); + }); + + it("filters out non-visible skills for non-owner callers", async () => { + vi.mocked(getAuthUserId).mockResolvedValue("users:other" as never); + const skills = [ + makeSkill("hidden-skill", { + moderationStatus: "hidden", + moderationReason: "pending.scan", + }), + ]; + const ctx = makeCtx(skills); + + const result = await handler(ctx as never, { + ownerUserId: "users:owner", + paginationOpts, + } as never); + + expect(result.page).toEqual([]); + }); +}); diff --git a/convex/skills.ts b/convex/skills.ts index 4ba9cde06..6abaa5278 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -2193,6 +2193,108 @@ export const list = query({ }, }); +/** Paginated dashboard query — used by the Publisher Dashboard to paginate + * large skill sets with client-side search. */ +export const listDashboardPaginated = query({ + args: { + ownerUserId: v.optional(v.id("users")), + ownerPublisherId: v.optional(v.id("publishers")), + paginationOpts: paginationOptsValidator, + }, + handler: async (ctx, args) => { + const userId = await getAuthUserId(ctx); + + // --- Publisher path (includes legacy user-owned skills) ---------------- + const ownerPublisherId = args.ownerPublisherId; + if (ownerPublisherId) { + const ownerPublisher = await ctx.db.get(ownerPublisherId); + const membership = + userId && + (await ctx.db + .query("publisherMembers") + .withIndex("by_publisher_user", (q) => + q.eq("publisherId", ownerPublisherId).eq("userId", userId), + ) + .unique()); + const isOwnDashboard = Boolean( + membership || (userId && ownerPublisher?.kind === "user" && ownerPublisher.linkedUserId === userId), + ); + + const result = await ctx.db + .query("skills") + .withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", ownerPublisherId)) + .filter((q) => q.eq(q.field("softDeletedAt"), undefined)) + .order("desc") + .paginate(args.paginationOpts); + + const filtered = isOwnDashboard + ? result.page + : await filterSkillsByActiveOwner(ctx, result.page); + const withBadges = await attachBadgesToSkills(ctx, filtered); + const page = toDashboardPage(withBadges, isOwnDashboard); + return { ...result, page }; + } + + // --- User path -------------------------------------------------------- + const ownerUserId = args.ownerUserId; + if (ownerUserId) { + const isOwnDashboard = Boolean(userId && userId === ownerUserId); + + const result = await ctx.db + .query("skills") + .withIndex("by_owner", (q) => q.eq("ownerUserId", ownerUserId)) + .filter((q) => q.eq(q.field("softDeletedAt"), undefined)) + .order("desc") + .paginate(args.paginationOpts); + + const filtered = isOwnDashboard + ? result.page + : await filterSkillsByActiveOwner(ctx, result.page); + const withBadges = await attachBadgesToSkills(ctx, filtered); + const page = toDashboardPage(withBadges, isOwnDashboard); + return { ...result, page }; + } + + return { page: [], isDone: true as const, continueCursor: "" }; + }, +}); + +/** Map skills to public shape, including pending-review items for owners. */ +function toDashboardPage(skills: Doc<"skills">[], isOwnDashboard: boolean) { + return skills + .map((skill) => { + const publicSkill = toPublicSkill(skill); + if (publicSkill) return publicSkill; + if (isOwnDashboard) { + const isPending = + skill.moderationStatus === "hidden" && skill.moderationReason === "pending.scan"; + if (isPending) { + const { badges } = skill; + return { + _id: skill._id, + _creationTime: skill._creationTime, + slug: skill.slug, + displayName: skill.displayName, + summary: skill.summary, + ownerUserId: skill.ownerUserId, + ownerPublisherId: skill.ownerPublisherId, + canonicalSkillId: skill.canonicalSkillId, + forkOf: skill.forkOf, + latestVersionId: skill.latestVersionId, + tags: skill.tags, + badges, + stats: skill.stats, + createdAt: skill.createdAt, + updatedAt: skill.updatedAt, + pendingReview: true as const, + }; + } + } + return null; + }) + .filter((skill): skill is NonNullable => Boolean(skill)); +} + export const listWithLatest = query({ args: { batch: v.optional(v.string()), diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx index 6045aaeb0..901a3f489 100644 --- a/src/routes/dashboard.tsx +++ b/src/routes/dashboard.tsx @@ -1,11 +1,12 @@ import { createFileRoute, Link } from "@tanstack/react-router"; -import { useQuery } from "convex/react"; +import { usePaginatedQuery, useQuery } from "convex/react"; import { AlertTriangle, ArrowDownToLine, CheckCircle2, Clock, GitBranch, + Loader2, Package, Plug, ShieldCheck, @@ -83,16 +84,23 @@ function Dashboard() { const selectedPublisher = publishers?.find((entry) => entry.publisher._id === selectedPublisherId) ?? null; - const mySkills = useQuery( - api.skills.list, + const skillsQueryArgs = selectedPublisher?.publisher.kind === "user" && me?._id - ? { ownerUserId: me._id, limit: 100 } + ? { ownerUserId: me._id } : selectedPublisherId - ? { ownerPublisherId: selectedPublisherId as Doc<"publishers">["_id"], limit: 100 } + ? { ownerPublisherId: selectedPublisherId as Doc<"publishers">["_id"] } : me?._id - ? { ownerUserId: me._id, limit: 100 } - : "skip", - ) as DashboardSkill[] | undefined; + ? { ownerUserId: me._id } + : "skip"; + const { + results: paginatedSkills, + status: skillsStatus, + loadMore, + } = usePaginatedQuery(api.skills.listDashboardPaginated, skillsQueryArgs, { + initialNumItems: 50, + }); + const mySkills = paginatedSkills as DashboardSkill[] | undefined; + const myPackages = useQuery( api.packages.list, selectedPublisherId @@ -197,6 +205,19 @@ function Dashboard() { ))} )} + {skillsStatus === "CanLoadMore" && ( +
+ +
+ )} + {skillsStatus === "LoadingMore" && ( +
+ + Loading more skills... +
+ )}
diff --git a/src/styles.css b/src/styles.css index f7c5e59ab..af33da07d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4050,6 +4050,24 @@ html.theme-transition::view-transition-new(theme) { color: var(--color-text); } +.dashboard-load-more { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 16px; + color: var(--muted); + font-size: 0.88rem; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.dashboard-spinner { + animation: spin 1s linear infinite; +} + .dashboard-list { border: 1px solid var(--card-border); border-radius: 14px;