-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
feat(dashboard): add pagination, server-side search #1480
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
026b292
79d04b4
97fc742
89fcb1b
c740953
2943426
2ff24f6
dc98b36
9d95314
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,3 +24,7 @@ coverage | |
| playwright-report | ||
| test-results | ||
| .playwright | ||
| .agents | ||
| /skills | ||
| skills-lock.json | ||
| .claude | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<TArgs, TResult = unknown> = { | ||
| _handler: (ctx: unknown, args: TArgs) => Promise<TResult>; | ||
| }; | ||
|
|
||
| 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<string, unknown> = {}) { | ||
| 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<typeof makeSkill>[]) { | ||
| 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([]); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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)) | ||
|
Comment on lines
+2223
to
+2225
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| .filter((q) => q.eq(q.field("softDeletedAt"), undefined)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Both paginated branches call Useful? React with 👍 / 👎. |
||
| .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<typeof skill> => Boolean(skill)); | ||
| } | ||
|
|
||
| export const listWithLatest = query({ | ||
| args: { | ||
| batch: v.optional(v.string()), | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.