From 5ec05482c7cf661fba5f0cd190459552b5316f3c Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:37:13 +0000 Subject: [PATCH 01/11] feat(hub): paginate and filter prototype gallery Add repository-scoped server search, signed cursor pages, and a searchable Me-first registration-author filter. Bound card and Build hydration to each selected page and preserve filters in browser history. Refs #33 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../prototype_authors_controller.rb | 27 + .../controllers/repositories_controller.rb | 11 +- .../repositories-show-view/index.test.tsx | 3 + .../prototype-author-filter/index.test.tsx | 87 ++++ .../prototype-author-filter/index.tsx | 215 ++++++++ .../prototype-author-filter/stories.tsx | 63 +++ .../prototype-author-filter/styles.css | 30 ++ .../features/prototype-index-header/index.tsx | 11 + .../use-prototype-listing/index.test.tsx | 221 ++++++++ .../hooks/use-prototype-listing/index.ts | 186 +++++++ .../repositories-show-page/index.test.tsx | 167 ++++-- .../pages/repositories-show-page/index.tsx | 116 ++++- .../pages/repositories-show-page/stories.tsx | 100 +++- .../pages/repositories-show-page/styles.css | 17 + .../pages/repositories-show-page/types.ts | 2 + .../storybook/fixtures/prototype-listing.ts | 34 ++ .../utils/prototype-listing/types.ts | 61 +++ hub/app/models/prototype.rb | 2 + hub/app/models/repository.rb | 1 + .../models/repository/prototype_listing.rb | 158 ++++++ hub/app/views/repositories/show.json.props | 54 +- hub/app/views/repositories/show.tsx | 6 +- hub/config/locales/gallery.en.yml | 23 +- hub/config/routes.rb | 1 + .../20261019000000_index_prototype_gallery.rb | 10 + hub/db/schema.rb | 4 +- hub/docs/gallery.md | 10 + hub/test/integration/gallery_test.rb | 10 +- .../integration/prototype_listing_test.rb | 487 ++++++++++++++++++ 29 files changed, 2043 insertions(+), 74 deletions(-) create mode 100644 hub/app/controllers/prototype_authors_controller.rb create mode 100644 hub/app/javascript/features/prototype-author-filter/index.test.tsx create mode 100644 hub/app/javascript/features/prototype-author-filter/index.tsx create mode 100644 hub/app/javascript/features/prototype-author-filter/stories.tsx create mode 100644 hub/app/javascript/features/prototype-author-filter/styles.css create mode 100644 hub/app/javascript/hooks/use-prototype-listing/index.test.tsx create mode 100644 hub/app/javascript/hooks/use-prototype-listing/index.ts create mode 100644 hub/app/javascript/storybook/fixtures/prototype-listing.ts create mode 100644 hub/app/javascript/utils/prototype-listing/types.ts create mode 100644 hub/app/models/repository/prototype_listing.rb create mode 100644 hub/db/migrate/20261019000000_index_prototype_gallery.rb create mode 100644 hub/test/integration/prototype_listing_test.rb diff --git a/hub/app/controllers/prototype_authors_controller.rb b/hub/app/controllers/prototype_authors_controller.rb new file mode 100644 index 00000000..08546f26 --- /dev/null +++ b/hub/app/controllers/prototype_authors_controller.rb @@ -0,0 +1,27 @@ +# Offers bounded registration-author choices only within an authorized repository. +class PrototypeAuthorsController < ApplicationController + include HasRepositoryWorkspace + + rescue_from GithubClient::Error, with: -> { head :service_unavailable } + + # @route GET /repositories/:repository_github_id/prototype_authors (repository_prototype_authors) + def index + load_repository_workspace + return if performed? + + page = @repository.prototype_listing.authors \ + user: Current.user, + query: params[:q], + cursor: params[:cursor] + response.headers["Cache-Control"] = "private, no-store" + render \ + json: { + authors: page.fetch(:authors).map do |author| + { id: author.id.to_s, login: author.login, name: author.name, avatarUrl: author.avatar_url } + end, + nextCursor: page.fetch(:next_cursor) + } + rescue Repository::PrototypeListing::InvalidQuery + head :bad_request + end +end diff --git a/hub/app/controllers/repositories_controller.rb b/hub/app/controllers/repositories_controller.rb index acd45e7a..531523ec 100644 --- a/hub/app/controllers/repositories_controller.rb +++ b/hub/app/controllers/repositories_controller.rb @@ -23,8 +23,13 @@ def show load_repository_workspace return if performed? - @prototypes = @repository.reviewable_prototypes.active.registered - .includes(:author, :current_build).order(registered_at: :desc, id: :desc) + @listing = @repository.prototype_listing.page \ + user: Current.user, + query: params[:q], + author: params[:author], + cursor: params[:cursor] + @prototypes = @listing.prototypes + ActiveRecord::Associations::Preloader.new(records: @prototypes, associations: [ :author, :current_build ]).call prototype_ids = @prototypes.map(&:id) @latest_builds = @prototypes.filter_map(&:current_build).index_by(&:prototype_id) missing_prototype_ids = prototype_ids - @latest_builds.keys @@ -47,6 +52,8 @@ def show @candidate_check_failed = true end end + rescue Repository::PrototypeListing::InvalidQuery + head :bad_request end # @route POST /repositories/:github_id/poll (poll_repository) diff --git a/hub/app/javascript/application/repositories-show-view/index.test.tsx b/hub/app/javascript/application/repositories-show-view/index.test.tsx index 43948a3f..0e97bb98 100644 --- a/hub/app/javascript/application/repositories-show-view/index.test.tsx +++ b/hub/app/javascript/application/repositories-show-view/index.test.tsx @@ -16,6 +16,9 @@ const state = vi.hoisted(() => ({ vi.mock("@hooks/use-page-data", () => ({ usePageData: () => state.data, })) +vi.mock("@hooks/use-prototype-listing", () => ({ + usePrototypeListing: () => ({}), +})) vi.mock("@pages/repositories-show-page", () => ({ RepositoriesShowPage: ({ managingPrototypes }: { managingPrototypes: boolean }) => ( {managingPrototypes ? "Managing prototypes" : "Idle"} diff --git a/hub/app/javascript/features/prototype-author-filter/index.test.tsx b/hub/app/javascript/features/prototype-author-filter/index.test.tsx new file mode 100644 index 00000000..1c0c0eb0 --- /dev/null +++ b/hub/app/javascript/features/prototype-author-filter/index.test.tsx @@ -0,0 +1,87 @@ +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { prototypeListing } from "@/storybook/fixtures/prototype-listing" +import type { PrototypeAuthorResults } from "@utils/prototype-listing/types" + +import { PrototypeAuthorFilter } from "." + +describe("PrototypeAuthorFilter()", () => { + it("pins Me first and selects the authenticated-user filter without a separate personal view", async () => { + const onChange = vi.fn() + const onSearch = vi.fn< + (query: string, cursor: string | null, signal: AbortSignal) => Promise + >(() => + Promise.resolve({ + authors: [{ id: "2", login: "mona", name: "Mona", avatarUrl: null }], + nextCursor: null, + }), + ) + render( + , + ) + const trigger = screen.getByRole("button", { name: "Author: All authors" }) + fireEvent.click(trigger) + const dialog = screen.getByRole("dialog", { name: "Author" }) + expect(within(dialog).getAllByRole("button")[0]).toHaveTextContent("Me") + expect(within(dialog).getByRole("searchbox")).toHaveFocus() + expect(await within(dialog).findByRole("button", { name: "@mona (Mona)" })).toBeInTheDocument() + fireEvent.click(within(dialog).getByRole("button", { name: "Me" })) + expect(onChange).toHaveBeenCalledExactlyOnceWith("me") + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + expect(trigger).toHaveFocus() + expect(screen.queryByText("My prototypes")).not.toBeInTheDocument() + }) + + it("replaces author option pages and cancels stale searches when the query changes", async () => { + const onSearch = vi + .fn< + ( + query: string, + cursor: string | null, + signal: AbortSignal, + ) => Promise + >(() => + Promise.resolve({ + authors: [{ id: "2", login: "mona", name: null, avatarUrl: null }], + nextCursor: "next", + }), + ) + .mockResolvedValueOnce({ + authors: [{ id: "1", login: "octocat", name: null, avatarUrl: null }], + nextCursor: "next", + }) + render( + , + ) + fireEvent.click(screen.getByRole("button", { name: "Author: All authors" })) + expect(await screen.findByRole("button", { name: "@octocat" })).toBeInTheDocument() + const firstSignal = onSearch.mock.calls[0]?.[2] + fireEvent.click(screen.getByRole("button", { name: "Next authors" })) + expect(await screen.findByRole("button", { name: "@mona" })).toBeInTheDocument() + expect(screen.queryByRole("button", { name: "@octocat" })).not.toBeInTheDocument() + expect(firstSignal?.aborted).toBe(true) + fireEvent.change(screen.getByRole("searchbox"), { target: { value: "mona" } }) + await waitFor(() => + expect(onSearch).toHaveBeenLastCalledWith("mona", null, expect.any(AbortSignal)), + ) + fireEvent.keyDown(screen.getByRole("searchbox"), { key: "Escape" }) + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + expect(onSearch.mock.calls.at(-1)?.[2].aborted).toBe(true) + }) + + it("reports lookup failures and retries without losing the pinned Me option", async () => { + const onSearch = vi + .fn(() => Promise.resolve({ authors: [], nextCursor: null })) + .mockRejectedValueOnce(new Error("Unavailable")) + render( + , + ) + fireEvent.click(screen.getByRole("button", { name: "Author: All authors" })) + expect(await screen.findByRole("alert")).toHaveTextContent(prototypeListing.copy.authorsFailed) + expect(screen.getByRole("button", { name: "Me" })).toBeInTheDocument() + fireEvent.click(screen.getByRole("button", { name: "Retry" })) + expect(await screen.findByRole("status")).toHaveTextContent(prototypeListing.copy.loading) + await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("No matching authors")) + }) +}) diff --git a/hub/app/javascript/features/prototype-author-filter/index.tsx b/hub/app/javascript/features/prototype-author-filter/index.tsx new file mode 100644 index 00000000..f7f6cb40 --- /dev/null +++ b/hub/app/javascript/features/prototype-author-filter/index.tsx @@ -0,0 +1,215 @@ +import { useEffect, useId, useRef, useState } from "react" + +import { CheckIcon, ChevronDownIcon } from "@primer/octicons-react" +import { Button, TextInput } from "@primer/react-brand" + +import type { PrototypeAuthorResults, PrototypeListing } from "@utils/prototype-listing/types" + +import "./styles.css" + +/** Offers Me first and searches bounded pages of this repository's registration authors. */ +export function PrototypeAuthorFilter({ + listing, + onChange, + onSearch, +}: { + listing: PrototypeListing + onChange: (author: string) => void + onSearch: ( + query: string, + cursor: string | null, + signal: AbortSignal, + ) => Promise +}) { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState("") + const [cursor, setCursor] = useState(null) + const [results, setResults] = useState(null) + const [failed, setFailed] = useState(false) + const [retry, setRetry] = useState(0) + const wrapper = useRef(null) + const input = useRef(null) + const trigger = useRef(null) + const id = useId() + const copy = listing.copy + + useEffect(() => { + if (!open) return + + input.current?.focus() + function dismiss(event: PointerEvent) { + if (event.target instanceof Node && !wrapper.current?.contains(event.target)) setOpen(false) + } + document.addEventListener("pointerdown", dismiss) + return () => document.removeEventListener("pointerdown", dismiss) + }, [open]) + + useEffect(() => { + if (!open) return + + const controller = new AbortController() + const timer = setTimeout(() => { + onSearch(query, cursor, controller.signal).then( + (value) => { + if (!controller.signal.aborted) setResults(value) + }, + () => { + if (!controller.signal.aborted) setFailed(true) + }, + ) + }, 200) + return () => { + clearTimeout(timer) + controller.abort() + } + }, [open, query, cursor, retry, onSearch]) + + function select(value: string) { + setOpen(false) + trigger.current?.focus() + onChange(value) + } + + return ( +
{ + if (event.key === "Escape" && open) { + event.preventDefault() + event.stopPropagation() + setOpen(false) + trigger.current?.focus() + } + }} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false) + }} + > + + + {open ? ( + + ) : null} +
+ ) +} diff --git a/hub/app/javascript/features/prototype-author-filter/stories.tsx b/hub/app/javascript/features/prototype-author-filter/stories.tsx new file mode 100644 index 00000000..3a55d14a --- /dev/null +++ b/hub/app/javascript/features/prototype-author-filter/stories.tsx @@ -0,0 +1,63 @@ +import type { Meta, StoryObj } from "@storybook/react-vite" +import { useState } from "react" +import { fn } from "storybook/test" + +import { prototypeListing } from "@/storybook/fixtures/prototype-listing" +import { mona } from "@/storybook/fixtures/users" + +import { PrototypeAuthorFilter } from "." + +const meta = { + title: "Features/Prototype Author Filter", + component: PrototypeAuthorFilter, + args: { + listing: prototypeListing, + onChange: fn(), + onSearch: fn(() => + Promise.resolve({ + authors: [{ id: "2", ...mona }], + nextCursor: null, + }), + ), + }, + render: function AuthorFilterStory(args) { + const [author, setAuthor] = useState(args.listing.author) + return ( + { + setAuthor(value) + args.onChange(value) + }} + /> + ) + }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Empty: Story = { + args: { onSearch: fn(() => Promise.resolve({ authors: [], nextCursor: null })) }, +} + +export const Single: Story = {} + +export const Me: Story = { + args: { listing: { ...prototypeListing, author: "me", authorLabel: "Me" } }, +} + +export const Failed: Story = { + args: { + onSearch: fn(() => Promise.reject(new Error("Unavailable"))), + }, +} + +export const Loading: Story = { + args: { onSearch: fn(() => new Promise(() => {})) }, +} diff --git a/hub/app/javascript/features/prototype-author-filter/styles.css b/hub/app/javascript/features/prototype-author-filter/styles.css new file mode 100644 index 00000000..b4500e13 --- /dev/null +++ b/hub/app/javascript/features/prototype-author-filter/styles.css @@ -0,0 +1,30 @@ +.prototype-author-filter { + position: relative; +} + +.prototype-author-filter__popup { + position: absolute; + z-index: 2; + top: calc(100% + var(--base-size-8)); + inset-inline-start: 0; + width: min(22rem, calc(100vw - 2rem)); + max-height: 28rem; + padding: var(--base-size-16); + overflow: auto; + border: var(--brand-borderWidth-thin) solid var(--hub-border-default); + border-radius: var(--hub-radius-medium); + background: var(--hub-canvas-default); + box-shadow: var(--hub-shadow-floating); +} + +.prototype-author-filter__choices, +.prototype-author-filter__choices > div { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--base-size-4); +} + +.prototype-author-filter__choices { + margin-top: var(--base-size-8); +} diff --git a/hub/app/javascript/features/prototype-index-header/index.tsx b/hub/app/javascript/features/prototype-index-header/index.tsx index 50b9931e..bacb3bbd 100644 --- a/hub/app/javascript/features/prototype-index-header/index.tsx +++ b/hub/app/javascript/features/prototype-index-header/index.tsx @@ -13,8 +13,10 @@ export function PrototypeIndexHeader({ heading, headingAs = "h1", onSearchChange, + onSearchSubmit, searchLabel, searchQuery, + searchMaxLength, searchShortcutEnabled = true, tools, }: { @@ -23,8 +25,10 @@ export function PrototypeIndexHeader({ heading: string headingAs?: "h1" | "h2" onSearchChange: (query: string) => void + onSearchSubmit?: () => void searchLabel: string searchQuery: string + searchMaxLength?: number searchShortcutEnabled?: boolean tools?: ReactNode }) { @@ -67,6 +71,13 @@ export function PrototypeIndexHeader({ placeholder={searchLabel} value={searchQuery} onChange={(event) => onSearchChange(event.target.value)} + maxLength={searchMaxLength} + onKeyDown={(event) => { + if (event.key === "Enter" && !event.nativeEvent.isComposing) { + event.preventDefault() + onSearchSubmit?.() + } + }} /> {searchQuery ? ( + } + /> + ) : null} + {filtered ? ( +
+ +
+ ) : null} {data.alert ? : null} {data.notice ? : null} @@ -159,13 +191,19 @@ export function RepositoriesShowPage({ /> ) : null} - {normalizedQuery && visiblePrototypes.length === 0 ? ( + {(filtered || data.listing.newestPath) && visiblePrototypes.length === 0 ? ( } heading={data.noSearchResultsHeading} description={data.noSearchResultsDescription} - actionLabel={data.clearSearchLabel} - onAction={() => setSearchQuery("")} + actionLabel={ + data.listing.newestPath ? data.listing.copy.newest : data.listing.copy.clearFilters + } + onAction={() => + data.listing.newestPath + ? controls.onNavigate(data.listing.newestPath) + : controls.onClearFilters() + } /> ) : data.prototypes.length === 0 ? ( data.newPrototypePath ? ( @@ -212,6 +250,44 @@ export function RepositoriesShowPage({ )} + + {data.listing.previousPath || data.listing.nextPath || data.listing.newestPath ? ( + + ) : null} diff --git a/hub/app/javascript/pages/repositories-show-page/stories.tsx b/hub/app/javascript/pages/repositories-show-page/stories.tsx index 9ebf8a38..3343c3df 100644 --- a/hub/app/javascript/pages/repositories-show-page/stories.tsx +++ b/hub/app/javascript/pages/repositories-show-page/stories.tsx @@ -1,6 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite" +import { fn } from "storybook/test" import { authenticatedHeaderData } from "@/storybook/fixtures/authenticated-header" +import { prototypeListing } from "@/storybook/fixtures/prototype-listing" import { welcomePrototype } from "@/storybook/fixtures/prototypes" import { githubEmpty, githubPrototype } from "@/storybook/fixtures/repositories" @@ -8,6 +10,7 @@ import { RepositoriesShowPage } from "." import type { RepositoriesShowPageData } from "./types" const data: RepositoriesShowPageData = { + listing: { ...prototypeListing, matchingCount: 0, totalCount: 0 }, ...authenticatedHeaderData, alert: null, collapseNavigationLabel: "Collapse sidebar", @@ -21,7 +24,7 @@ const data: RepositoriesShowPageData = { managePrototypesLabel: "Manage prototypes", managePrototypesPath: "/repositories/2001/prototypes/manage", prototypeColumnLabel: "Prototype", - ownerColumnLabel: "Owner", + ownerColumnLabel: "Author", buildColumnLabel: "Build", passingLabel: "passing", notPassingLabel: "not passing", @@ -58,7 +61,26 @@ const data: RepositoriesShowPageData = { const meta = { title: "Pages/Repositories/Show", component: RepositoriesShowPage, - args: { data }, + args: { + data, + controls: { + query: "", + pending: false, + error: false, + onQueryChange: fn(), + onQuerySubmit: fn(), + onAuthorChange: fn(), + onClearFilters: fn(), + onNavigate: fn(), + onRetry: fn(), + loadAuthors: fn(() => + Promise.resolve({ + authors: [], + nextCursor: null, + }), + ), + }, + }, parameters: { layout: "fullscreen" }, } satisfies Meta @@ -175,9 +197,79 @@ export const WideViewSwitching: Story = { } export const Searched: Story = { + args: { + controls: { ...meta.args.controls, query: "Navigation" }, + data: { + ...data, + listing: { ...prototypeListing, query: "Navigation" }, + prototypes: [ + { + ...welcomePrototype, + id: "2", + name: "Navigation", + path: "/repositories/2001/prototypes/2", + }, + ], + prototypesCountLabel: "1 prototype", + }, + }, +} + +export const MeSelected: Story = { + ...Single, + args: { + ...Single.args, + data: { + ...data, + listing: { ...prototypeListing, author: "me", authorLabel: "Me" }, + prototypes: [welcomePrototype], + prototypesCountLabel: "1 prototype", + }, + }, +} + +export const Paginated: Story = { ...Multiple, - play: async ({ canvas, userEvent }) => { - await userEvent.type(canvas.getByRole("searchbox", { name: "Search prototypes" }), "Navigation") + args: { + ...Multiple.args, + data: { + ...data, + ...Multiple.args?.data, + listing: { + ...prototypeListing, + matchingCount: 50, + totalCount: 50, + nextPath: "/repositories/2001?cursor=next", + copy: { ...prototypeListing.copy, shown: "2 shown" }, + }, + prototypesCountLabel: "50 prototypes", + }, + }, +} + +export const NoMatches: Story = { + args: { + data: { + ...data, + listing: { ...prototypeListing, author: "me", authorLabel: "Me", matchingCount: 0 }, + workflowInstallation: undefined, + }, + }, +} + +export const Updating: Story = { + ...Single, + args: { + ...Single.args, + controls: { ...meta.args.controls, pending: true }, + }, +} + +export const RequestFailed: Story = { + ...Single, + args: { + ...Single.args, + controls: { ...meta.args.controls, error: true }, }, } diff --git a/hub/app/javascript/pages/repositories-show-page/styles.css b/hub/app/javascript/pages/repositories-show-page/styles.css index d825ba9b..002ece25 100644 --- a/hub/app/javascript/pages/repositories-show-page/styles.css +++ b/hub/app/javascript/pages/repositories-show-page/styles.css @@ -9,6 +9,14 @@ min-height: 2.25rem; align-items: center; gap: 1rem; + flex-wrap: wrap; +} + +.repository-detail__pagination { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: var(--hub-space-16); } .repository-detail__count { @@ -119,6 +127,15 @@ } .repository-detail__toolbar { + position: relative; justify-content: flex-end; } + + .repository-detail__toolbar .prototype-author-filter { + position: static; + } + + .repository-detail__toolbar .prototype-author-filter__popup { + width: min(22rem, 100%); + } } diff --git a/hub/app/javascript/pages/repositories-show-page/types.ts b/hub/app/javascript/pages/repositories-show-page/types.ts index b2c8200b..c950ed44 100644 --- a/hub/app/javascript/pages/repositories-show-page/types.ts +++ b/hub/app/javascript/pages/repositories-show-page/types.ts @@ -2,8 +2,10 @@ import type { RepositoryWorkspaceData } from "@features/repository-workspace/typ import type { WorkflowInstallationData } from "@features/workflow-installation/types" import type { PrototypeCardData } from "@components/prototype-card/types" +import type { PrototypeListing } from "@utils/prototype-listing/types" export interface RepositoriesShowPageData extends RepositoryWorkspaceData { + listing: PrototypeListing alert: string | null candidateCheckFailed?: string clearSearchLabel: string diff --git a/hub/app/javascript/storybook/fixtures/prototype-listing.ts b/hub/app/javascript/storybook/fixtures/prototype-listing.ts new file mode 100644 index 00000000..e551c1a9 --- /dev/null +++ b/hub/app/javascript/storybook/fixtures/prototype-listing.ts @@ -0,0 +1,34 @@ +import type { PrototypeListing } from "@utils/prototype-listing/types" + +export const prototypeListing: PrototypeListing = { + query: "", + author: "", + authorLabel: "All authors", + matchingCount: 1, + totalCount: 1, + previousPath: null, + nextPath: null, + newestPath: null, + authorsPath: "/repositories/2001/prototype_authors", + path: "/repositories/2001", + copy: { + author: "Author", + me: "Me", + allAuthors: "All authors", + searchAuthors: "Search authors", + moreAuthors: "Next authors", + firstAuthors: "First authors", + noAuthors: "No matching authors", + loading: "Loading authors", + retry: "Retry", + authorsFailed: "Hub could not load authors. Try again.", + resultsFailed: "Hub could not load prototypes. Your previous results are still shown.", + clearFilters: "Clear filters", + previous: "Previous", + next: "Next", + newest: "Newest results", + pagination: "Prototype pages", + updating: "Updating prototypes", + shown: "1 shown", + }, +} diff --git a/hub/app/javascript/utils/prototype-listing/types.ts b/hub/app/javascript/utils/prototype-listing/types.ts new file mode 100644 index 00000000..164a7ab4 --- /dev/null +++ b/hub/app/javascript/utils/prototype-listing/types.ts @@ -0,0 +1,61 @@ +export interface PrototypeAuthor { + id: string + login: string + name: string | null + avatarUrl: string | null +} + +export interface PrototypeAuthorResults { + authors: PrototypeAuthor[] + nextCursor: string | null +} + +export interface PrototypeListingControls { + query: string + pending: boolean + error: boolean + onQueryChange: (query: string) => void + onQuerySubmit: () => void + onAuthorChange: (author: string) => void + onClearFilters: () => void + onNavigate: (path: string) => void + onRetry: () => void + loadAuthors: ( + query: string, + cursor: string | null, + signal: AbortSignal, + ) => Promise +} + +export interface PrototypeListing { + query: string + author: string + authorLabel: string + matchingCount: number + totalCount: number + previousPath: string | null + nextPath: string | null + newestPath: string | null + authorsPath: string + path: string + copy: { + author: string + me: string + allAuthors: string + searchAuthors: string + moreAuthors: string + firstAuthors: string + noAuthors: string + loading: string + retry: string + authorsFailed: string + resultsFailed: string + clearFilters: string + previous: string + next: string + newest: string + pagination: string + updating: string + shown: string + } +} diff --git a/hub/app/models/prototype.rb b/hub/app/models/prototype.rb index a9f593f6..8754ca7d 100644 --- a/hub/app/models/prototype.rb +++ b/hub/app/models/prototype.rb @@ -29,6 +29,8 @@ # index_prototypes_on_active_branch (repository_id,branch_name) UNIQUE WHERE branch_deleted_at IS NULL # index_prototypes_on_author_id (author_id) # index_prototypes_on_current_build_id (current_build_id) +# index_prototypes_on_gallery_author (repository_id,author_id,registered_at,id) WHERE branch_deleted_at IS NULL AND registered_at IS NOT NULL +# index_prototypes_on_gallery_order (repository_id,registered_at,id) WHERE branch_deleted_at IS NULL AND registered_at IS NOT NULL # index_prototypes_on_repository_id (repository_id) # # Foreign Keys diff --git a/hub/app/models/repository.rb b/hub/app/models/repository.rb index 103d72b2..fb2d0a5f 100644 --- a/hub/app/models/repository.rb +++ b/hub/app/models/repository.rb @@ -59,6 +59,7 @@ class Repository < ApplicationRecord has_object :branch_presence has_object :local_discovery has_object :prototype_candidates + has_object :prototype_listing has_object :workflow_installation has_object :workflow_run_discoveries diff --git a/hub/app/models/repository/prototype_listing.rb b/hub/app/models/repository/prototype_listing.rb new file mode 100644 index 00000000..999d0d33 --- /dev/null +++ b/hub/app/models/repository/prototype_listing.rb @@ -0,0 +1,158 @@ +# Selects bounded gallery pages and registration authors within one repository. +class Repository::PrototypeListing < ActiveRecord::AssociatedObject + class InvalidQuery < StandardError; end + + PAGE_SIZE = 24 + AUTHORS_PAGE_SIZE = 20 + QUERY_MAXIMUM_LENGTH = 200 + Page = Data.define \ + :prototypes, :query, :author, :author_user, :matching_count, :total_count, + :previous_cursor, :next_cursor + + # Filters before paging, leaving Build hydration to the selected records. + def page(user:, query: nil, author: nil, cursor: nil) + query = normalized_query(query) + author = normalized_author(author) + author_id = author == "me" ? user.id : author.presence&.to_i + relation = base + relation = relation.where(author_id:) if author_id + if query.present? + relation = relation.left_joins(:author).where \ + "prototypes.name LIKE :query ESCAPE '\\' OR prototypes.description LIKE :query ESCAPE '\\' " \ + "OR prototypes.branch_name LIKE :query ESCAPE '\\' OR users.login LIKE :query ESCAPE '\\' " \ + "OR users.name LIKE :query ESCAPE '\\'", + query: "%#{Prototype.sanitize_sql_like(query)}%" + end + total_count = base.count + matching_count = query.empty? && author.empty? ? total_count : relation.count + scope = cursor_scope(user, "prototypes", query:, author:) + boundary = read_cursor(cursor, scope) + selected = relation + if boundary + selected = before_boundary(selected, boundary, newer: boundary.fetch("direction") == "previous") + end + newer = boundary&.fetch("direction") == "previous" + rows = selected.order(registered_at: newer ? :asc : :desc, id: newer ? :asc : :desc) + .limit(PAGE_SIZE + 1).to_a.first(PAGE_SIZE) + rows.reverse! if newer + + previous_cursor = nil + next_cursor = nil + if rows.any? + first = prototype_boundary(rows.first, "previous") + last = prototype_boundary(rows.last, "next") + previous_cursor = write_cursor(scope, first) if before_boundary(relation, first, newer: true).exists? + next_cursor = write_cursor(scope, last) if before_boundary(relation, last, newer: false).exists? + end + + Page.new \ + prototypes: rows, + query:, + author:, + author_user: author_id && User.where(id: base.select(:author_id)).find_by(id: author_id), + matching_count:, + total_count:, + previous_cursor:, + next_cursor: + end + + # Searches all eligible registration authors without enumerating the gallery. + def authors(user:, query: nil, cursor: nil) + query = normalized_query(query) + relation = User.where(id: base.select(:author_id)).where.not(id: user.id) + if query.present? + relation = relation.where \ + "login LIKE :query ESCAPE '\\' OR name LIKE :query ESCAPE '\\'", + query: "%#{User.sanitize_sql_like(query)}%" + end + scope = cursor_scope(user, "authors", query:) + boundary = read_cursor(cursor, scope) + if boundary + raise InvalidQuery unless boundary["login"].is_a?(String) && boundary["login"].length <= 255 + + relation = relation.where \ + "login > :login OR (login = :login AND id > :id)", + login: boundary.fetch("login"), id: boundary.fetch("id") + end + rows = relation.order(:login, :id).limit(AUTHORS_PAGE_SIZE + 1).to_a + authors = rows.first(AUTHORS_PAGE_SIZE) + next_cursor = if rows.size > AUTHORS_PAGE_SIZE + write_cursor(scope, { "login" => authors.last.login, "id" => authors.last.id }) + end + { authors:, next_cursor: } + end + + private + + # Shares eligibility with the gallery, not all stored branch owners. + def base + record.reviewable_prototypes.active.registered + end + + # Rejects malformed or oversized input rather than silently changing its meaning. + def normalized_query(value) + return "" if value.nil? + raise InvalidQuery unless value.is_a?(String) && value.length <= QUERY_MAXIMUM_LENGTH + + value.strip + end + + # Named authors use immutable saved IDs; Me always resolves on the server. + def normalized_author(value) + return "" if value.nil? || value == "" + return value if value == "me" + raise InvalidQuery unless value.is_a?(String) && value.match?(/\A[1-9]\d{0,17}\z/) + + value + end + + # Signs the query and viewer as well as the boundary to reject mixed continuations. + def cursor_scope(user, kind, query:, author: "") + { "version" => 1, "repository" => record.id, "user" => user.id, "kind" => kind, "query" => query, "author" => author } + end + + # Validates only server-issued boundaries, never treating them as authorization. + def read_cursor(value, scope) + return if value.nil? + raise InvalidQuery unless value.is_a?(String) && value.bytesize <= 2_048 + + payload = verifier.verified(value) + unless payload.is_a?(Hash) && payload.slice(*scope.keys) == scope && + payload["id"].is_a?(Integer) && payload["id"].positive? + raise InvalidQuery + end + + payload + end + + # Keeps full timestamp precision for prototypes registered at the same time. + def prototype_boundary(prototype, direction) + { "time" => prototype.registered_at.iso8601(6), "id" => prototype.id, "direction" => direction } + end + + # Uses strict tuple boundaries so new registrations do not shift later pages. + def before_boundary(relation, boundary, newer:) + unless boundary["time"].is_a?(String) && %w[previous next].include?(boundary["direction"]) + raise InvalidQuery + end + + time = Time.iso8601(boundary.fetch("time")) + operator = newer ? ">" : "<" + relation.where \ + "prototypes.registered_at #{operator} :time " \ + "OR (prototypes.registered_at = :time AND prototypes.id #{operator} :id)", + time:, id: boundary.fetch("id") + rescue ArgumentError + raise InvalidQuery + end + + # Adds the boundary to the exact source query. + def write_cursor(scope, boundary) + verifier.generate(scope.merge(boundary)) + end + + # Separates gallery tokens from other signed Hub values. + def verifier + Rails.application.message_verifier("prototype-listing") + end +end diff --git a/hub/app/views/repositories/show.json.props b/hub/app/views/repositories/show.json.props index 66b7fb04..879a6877 100644 --- a/hub/app/views/repositories/show.json.props +++ b/hub/app/views/repositories/show.json.props @@ -39,7 +39,7 @@ end json.prototypesHeading t("repositories.show.prototypes_heading") json.alert flash[:alert] json.notice flash[:notice] -json.prototypesCountLabel t("repositories.show.prototype_count", count: @prototypes.length) +json.prototypesCountLabel t("repositories.show.prototype_count", count: @listing.matching_count) json.prototypeCountUnitOne t("repositories.show.prototype_count_unit_one") json.prototypeCountUnitOther t("repositories.show.prototype_count_unit_other") json.searchLabel t("repositories.show.search") @@ -59,6 +59,39 @@ json.emptyHeading t("repositories.show.empty_heading") json.emptyLabel t(@repository.local? ? "repositories.show.local_empty" : "repositories.show.empty") json.buildPrototypeLabel t("repositories.show.build_prototype") +listing_params = { q: @listing.query.presence, author: @listing.author.presence }.compact +json.listing do + json.query @listing.query + json.author @listing.author + author_label = if @listing.author == "me" + t("repositories.show.filters.me") + elsif @listing.author_user + "@#{@listing.author_user.login}" + elsif @listing.author.present? + t("repositories.show.unknown_author") + else + t("repositories.show.filters.all_authors") + end + json.authorLabel author_label + json.matchingCount @listing.matching_count + json.totalCount @listing.total_count + previous_path = @listing.previous_cursor && + repository_path(@repository.route_id, **listing_params, cursor: @listing.previous_cursor) + next_path = @listing.next_cursor && + repository_path(@repository.route_id, **listing_params, cursor: @listing.next_cursor) + json.previousPath previous_path + json.nextPath next_path + json.newestPath(params[:cursor].present? ? repository_path(@repository.route_id, **listing_params) : nil) + json.authorsPath repository_prototype_authors_path(@repository.route_id) + json.path repository_path(@repository.route_id) + json.copy do + %w[author me all_authors search_authors more_authors first_authors no_authors loading retry + authors_failed results_failed clear_filters previous next newest pagination updating shown].each do |key| + json.set! key.camelize(:lower), t("repositories.show.filters.#{key}", count: @prototypes.length) + end + end +end + if @repository_writable json.managePrototypesPath repository_prototype_management_path(@repository.route_id) json.managePrototypesLabel t("repositories.show.manage_prototypes") @@ -136,24 +169,19 @@ json.prototypes do json.ownerLabel "@#{Current.user.login}" json.url nil end - elsif latest_build&.commit_author_login + elsif prototype.author json.author do - json.avatarUrl latest_build.commit_author_avatar_url - json.label t("repositories.show.card_author", login: latest_build.commit_author_login) - json.ownerLabel "@#{latest_build.commit_author_login}" - json.url latest_build.commit_author.profile_url + json.avatarUrl prototype.author.avatar_url + json.label t("repositories.show.card_author", login: prototype.author.login) + json.ownerLabel "@#{prototype.author.login}" + json.url "https://github.com/#{prototype.author.login}" end else json.author do json.avatarUrl nil - if latest_build&.commit_author_name - json.label t("repositories.show.card_author_name", name: latest_build.commit_author_name) - json.ownerLabel latest_build.commit_author_name - else - json.label t("repositories.show.unknown_author") - json.ownerLabel t("repositories.show.unknown_author") - end + json.label t("repositories.show.unknown_author") + json.ownerLabel t("repositories.show.unknown_author") json.url nil end diff --git a/hub/app/views/repositories/show.tsx b/hub/app/views/repositories/show.tsx index b6fd00e4..30ab24cf 100644 --- a/hub/app/views/repositories/show.tsx +++ b/hub/app/views/repositories/show.tsx @@ -3,14 +3,18 @@ import { useSelector } from "react-redux" import type { AppState } from "@application/store" import { usePageData } from "@hooks/use-page-data" import { useRepositoryInbox } from "@hooks/use-repository-inbox" +import { usePrototypeListing } from "@hooks/use-prototype-listing" import { RepositoriesShowPage } from "@pages/repositories-show-page" import type { RepositoriesShowPageData } from "@pages/repositories-show-page/types" export default function RepositoriesShowView() { const data = useRepositoryInbox(usePageData()) + const controls = usePrototypeListing(data.listing) const managingPrototypes = useSelector( (state: AppState) => state.navigationRequest.path === data.managePrototypesPath, ) - return + return ( + + ) } diff --git a/hub/config/locales/gallery.en.yml b/hub/config/locales/gallery.en.yml index 525d78e0..52049b9d 100644 --- a/hub/config/locales/gallery.en.yml +++ b/hub/config/locales/gallery.en.yml @@ -23,12 +23,12 @@ en: search: Search prototypes clear_search: Clear search no_search_results_heading: No prototypes found - no_search_results_description: Try a different search, or clear the current search to browse all prototypes. + no_search_results_description: Try a different search or author, or clear the filters to browse all prototypes. view: Prototype layout gallery_view: Gallery view list_view: List view prototype_column: Prototype - owner_column: Owner + owner_column: Author build_column: Build passing: passing not_passing: not passing @@ -49,6 +49,25 @@ en: unknown_author: Author unavailable screenshot_alt: "Desktop screenshot of %{branch}, build %{number}" preview_fallback: No successful preview is available. + filters: + author: Author + me: Me + all_authors: All authors + search_authors: Search authors + more_authors: Next authors + first_authors: First authors + no_authors: No matching authors + loading: Loading authors + retry: Retry + authors_failed: Hub could not load authors. Try again. + results_failed: Hub could not load prototypes. Your previous results are still shown. + clear_filters: Clear filters + previous: Previous + next: Next + newest: Newest results + pagination: Prototype pages + updating: Updating prototypes + shown: "%{count} shown" workflow: heading: Prototype workflow different_heading: Replica workflow update available diff --git a/hub/config/routes.rb b/hub/config/routes.rb index 71540fdc..2b4f2099 100644 --- a/hub/config/routes.rb +++ b/hub/config/routes.rb @@ -111,6 +111,7 @@ constraints: { memory_id: /mem_[0-9a-f]{24}/ } end get "new", to: "new_prototypes#show", as: :new_prototype + resources :prototype_authors, only: :index get "replica", to: "replica_browses#show", as: :replica_browse resources :templates, only: %i[index show], constraints: { id: /[a-z0-9]+(?:-[a-z0-9]+)*/ } get "templates/:template_id/builds/:build_id/screenshots/:size", to: "template_screenshots#show", diff --git a/hub/db/migrate/20261019000000_index_prototype_gallery.rb b/hub/db/migrate/20261019000000_index_prototype_gallery.rb new file mode 100644 index 00000000..e44d385d --- /dev/null +++ b/hub/db/migrate/20261019000000_index_prototype_gallery.rb @@ -0,0 +1,10 @@ +class IndexPrototypeGallery < ActiveRecord::Migration[8.1] + def change + add_index :prototypes, [ :repository_id, :registered_at, :id ], + name: "index_prototypes_on_gallery_order", + where: "branch_deleted_at IS NULL AND registered_at IS NOT NULL" + add_index :prototypes, [ :repository_id, :author_id, :registered_at, :id ], + name: "index_prototypes_on_gallery_author", + where: "branch_deleted_at IS NULL AND registered_at IS NOT NULL" + end +end diff --git a/hub/db/schema.rb b/hub/db/schema.rb index 97846c4d..06122693 100644 --- a/hub/db/schema.rb +++ b/hub/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_10_15_060000) do +ActiveRecord::Schema[8.1].define(version: 2026_10_19_000000) do create_table "analytics_deliveries", force: :cascade do |t| t.integer "attempts", default: 0, null: false t.datetime "created_at", null: false @@ -411,8 +411,10 @@ t.string "retired_head_sha" t.index ["author_id"], name: "index_prototypes_on_author_id" t.index ["current_build_id"], name: "index_prototypes_on_current_build_id" + t.index ["repository_id", "author_id", "registered_at", "id"], name: "index_prototypes_on_gallery_author", where: "branch_deleted_at IS NULL AND registered_at IS NOT NULL" t.index ["repository_id", "branch_name", "branch_deleted_at"], name: "idx_on_repository_id_branch_name_branch_deleted_at_37960952d6" t.index ["repository_id", "branch_name"], name: "index_prototypes_on_active_branch", unique: true, where: "branch_deleted_at IS NULL" + t.index ["repository_id", "registered_at", "id"], name: "index_prototypes_on_gallery_order", where: "branch_deleted_at IS NULL AND registered_at IS NOT NULL" t.index ["repository_id"], name: "index_prototypes_on_repository_id" t.check_constraint "description IS NULL OR length(trim(description)) BETWEEN 1 AND 500", name: "prototypes_description" t.check_constraint "length(trim(branch_name)) BETWEEN 1 AND 255", name: "prototypes_branch_name" diff --git a/hub/docs/gallery.md b/hub/docs/gallery.md index 69c81ff8..09a21be0 100644 --- a/hub/docs/gallery.md +++ b/hub/docs/gallery.md @@ -60,6 +60,16 @@ Operators configure the video through the [deployment settings](deployment.md#we ## Browse prototypes +Prototypes appear in pages of up to 24, newest registration first. **Previous**, **Next**, and **Newest results** keep the current search and author filter. Hub loads card and Build data only for the selected page. A new registration appears on the newest page without shifting the boundary used to continue browsing older results. + +Search matches prototype names, descriptions, branches, and registration authors across the full selected repository, not only the visible page. Search treats punctuation such as `%` and `_` literally. SQLite's built-in matching ignores ASCII letter case; non-ASCII letters retain SQLite's case behavior. The count shows all matches, with the number shown on the current page when fewer are displayed. + +The **Author** dropdown puts **Me** first, followed by **All authors** and searchable pages of the repository's registration authors. **Me** uses the signed-in person's saved identity. Cards and the list's **Author** column show the first person who added the prototype to Hub; later commits by another person do not change attribution or author-filter results. Prototypes without a saved author remain visible with no author filter and display **Author unavailable**. Local mode keeps its existing local-identity presentation and applies author filters to saved registration authors. + +Search and author filters combine. Applying either starts again at the newest matching page; **Clear filters** removes both. The URL preserves applied search, author, and page state across reloads and browser history. Switching repositories starts without those filters. Author choices load in bounded pages and are not restricted to authors on the visible prototype page. Clearing or changing author-option search starts its choices from the beginning. + +While Hub fetches results, it keeps the previous cards visible and announces the update. A failed request keeps those cards with an explicit error and **Retry**. No matches has its own empty state, separate from a repository with no prototypes. If a previously valid page has become empty, **Newest results** returns to the current matching collection. + An empty Prototypes page shows a branch Octicon, a heading, and guidance. For GitHub repositories, **Build prototype** opens **Create**. Local repositories keep their branch-discovery guidance without offering the Copilot app flow. The **Create** sidebar link opens a centered brief composer for the selected repository, with **What should we create?** above it and the **Templates** catalog below. Choose a template inside the composer before **Add details**, or open a catalog card to preview it first. The picker starts with **Default**. Choosing another template includes its quoted route, query, and fragment in the prompt and instructs the agent to set it as the new prototype's landing page through `startPage` in `.prototype`. Choosing Default removes that extra context. Catalog search does not change the selected template, brief, or saved details. Unavailable or corrupt template metadata returns an explicit service failure rather than an incomplete collection. diff --git a/hub/test/integration/gallery_test.rb b/hub/test/integration/gallery_test.rb index 929f7d48..11c1339a 100644 --- a/hub/test/integration/gallery_test.rb +++ b/hub/test/integration/gallery_test.rb @@ -124,6 +124,11 @@ class GalleryTest < ActionDispatch::IntegrationTest assert_response :success data = JSON.parse(response.body).fetch("data") assert_empty data.fetch("prototypes") + assert_equal 0, data.fetch("listing").fetch("matchingCount") + assert_equal 0, data.fetch("listing").fetch("totalCount") + %w[previousPath nextPath newestPath].each do |key| + assert_nil data.fetch("listing").fetch(key) + end assert_equal "No prototypes yet", data.fetch("emptyHeading") assert_equal "Build prototype", data.fetch("buildPrototypeLabel") assert_equal repository_new_prototype_path(repository.github_id), data.fetch("newPrototypePath") @@ -135,7 +140,10 @@ class GalleryTest < ActionDispatch::IntegrationTest sign_in repository = Repository.find_by!(github_id: 2001) prototype = prototypes.create(repository:, branch_name: "prototype/welcome") - prototype.register!(name: "Welcome", description: "Try the new welcome flow.") + prototype.register! \ + name: "Welcome", + description: "Try the new welcome flow.", + author: User.find_by!(github_id: 1001) build_with_state(prototype, 1, "successful", "a", Time.current) latest = build_with_state(prototype, 2, "running", "b", Time.current) latest.commit_author.record!(commit_payload(latest.commit_sha)) diff --git a/hub/test/integration/prototype_listing_test.rb b/hub/test/integration/prototype_listing_test.rb new file mode 100644 index 00000000..20827984 --- /dev/null +++ b/hub/test/integration/prototype_listing_test.rb @@ -0,0 +1,487 @@ +require "test_helper" +require "fileutils" +require "open3" + +class PrototypeListingTest < ActionDispatch::IntegrationTest + setup do + github_app_configurations.create_installed + end + + test "returns at most 24 registered reviewable prototypes in stable registration order" do + repository, user = sign_in_repository + time = Time.current.change(usec: 123456) + older = registered_prototype(repository, author: user, at: time - 1.day) + tied = 25.times.map { registered_prototype(repository, author: user, at: time) } + prototypes.create(repository:, branch_name: "prototype/unregistered", author: user) + prototypes.create(repository:, branch_name: repository.default_branch) + prototypes.create(repository:, branch_name: "replica/install-workflow") + retired = registered_prototype(repository, author: user) + retired.update!(branch_deleted_at: Time.current) + foreign = repositories.create(github_id: 2002, name_with_owner: "github/other") + stub_github_permissions([ repository_payload(2002, "github/other") ], { 2002 => "pull" }) + registered_prototype(foreign, author: user) + older.update!(description: "Edited after the newer registrations") + + data = listing(repository) + + assert_equal tied.reverse.first(24).map { _1.id.to_s }, data.fetch("prototypes").pluck("id") + metadata = data.fetch("listing") + assert_equal "", metadata.fetch("query") + assert_equal "", metadata.fetch("author") + assert_kind_of String, metadata.fetch("authorLabel") + assert_equal 26, metadata.fetch("matchingCount") + assert_equal 26, metadata.fetch("totalCount") + assert_nil metadata.fetch("previousPath") + assert_nil metadata.fetch("newestPath") + assert_kind_of String, metadata.fetch("nextPath") + assert_equal authors_path(repository), metadata.fetch("authorsPath") + end + + test "filters the full collection before limiting and preserves filters going next and previous" do + repository, user = sign_in_repository + other = create_author("other-reviewer") + time = Time.current + matching = 49.times.map do |index| + registered_prototype(repository, author: user, name: "Needle #{index}", at: time - 1.day) + end + 25.times { registered_prototype(repository, author: other, name: "Needle", at: time) } + registered_prototype(repository, author: user, name: "Unrelated", at: time) + expected = matching.reverse.map { _1.id.to_s } + + first = listing(repository, q: "nEeDlE", author: user.id.to_s) + assert_equal expected.first(24), first.fetch("prototypes").pluck("id") + assert_equal 49, first.fetch("listing").fetch("matchingCount") + assert_equal 75, first.fetch("listing").fetch("totalCount") + + next_path = first.fetch("listing").fetch("nextPath") + assert_filters next_path, q: "nEeDlE", author: user.id.to_s + second = listing_at(next_path) + assert_equal expected.slice(24, 24), second.fetch("prototypes").pluck("id") + assert_equal 49, second.fetch("listing").fetch("matchingCount") + assert_filters second.fetch("listing").fetch("newestPath"), q: "nEeDlE", author: user.id.to_s + previous_path = second.fetch("listing").fetch("previousPath") + assert_filters previous_path, q: "nEeDlE", author: user.id.to_s + previous = listing_at(previous_path) + assert_equal expected.first(24), previous.fetch("prototypes").pluck("id") + assert_nil previous.fetch("listing").fetch("previousPath") + + third = listing_at(second.fetch("listing").fetch("nextPath")) + assert_equal expected.last(1), third.fetch("prototypes").pluck("id") + assert_nil third.fetch("listing").fetch("nextPath") + assert_equal expected.first(24), + listing_at(third.fetch("listing").fetch("newestPath")).fetch("prototypes").pluck("id") + end + + test "new registrations between next requests do not repeat visited prototypes" do + repository, user = sign_in_repository + time = Time.current + originals = 50.times.map { registered_prototype(repository, author: user, at: time) } + first = listing(repository) + registered_prototype(repository, author: user, at: time + 1.second) + + second = listing_at(first.fetch("listing").fetch("nextPath")) + third = listing_at(second.fetch("listing").fetch("nextPath")) + + visited = [ first, second, third ].flat_map { _1.fetch("prototypes").pluck("id") } + assert_equal originals.reverse.map { _1.id.to_s }, visited + assert_equal 51, second.fetch("listing").fetch("matchingCount") + assert_nil third.fetch("listing").fetch("nextPath") + end + + test "hydrates only the selected page and its Builds rather than the full gallery" do + repository, user = sign_in_repository + time = Time.current + records = 60.times.map do |index| + prototype = registered_prototype(repository, author: user, at: time) + builds.create_successful(prototype:, number: 1) + latest = builds.create(prototype:, number: 2, commit_sha: "b" * 40) + prototype.update!(current_build: latest) if index.even? + prototype + end + instantiated = Hash.new(0) + subscriber = lambda do |*arguments| + payload = arguments.last + instantiated[payload.fetch(:class_name)] += payload.fetch(:record_count) + end + data = nil + + ActiveSupport::Notifications.subscribed(subscriber, "instantiation.active_record") do + data = listing(repository) + end + + cards = data.fetch("prototypes") + assert_equal records.reverse.first(24).map { _1.id.to_s }, cards.pluck("id") + assert_equal 60, data.fetch("listing").fetch("totalCount") + cards.each do |card| + assert_equal "queued", card.fetch("latestBuild").fetch("state") + assert_equal 1, card.fetch("reviewBuild").fetch("number") + end + assert_operator instantiated.fetch("Prototype"), :<=, 25 + assert_operator instantiated.fetch("Build"), :<=, 24 * 2 + end + + test "searches names descriptions branches and registration author login and name case insensitively" do + repository, user = sign_in_repository + by_login = create_author("needle-owner", name: "Another person") + by_name = create_author("name-owner", name: "Needle Person") + matches = [ + registered_prototype(repository, author: user, name: "A NEEDLE design"), + registered_prototype(repository, author: user, description: "A Needle description"), + registered_prototype(repository, author: user, branch_name: "prototype/needle-branch"), + registered_prototype(repository, author: by_login), + registered_prototype(repository, author: by_name) + ] + unrelated = registered_prototype(repository, author: user) + build = builds.create(prototype: unrelated) + build.commit_author.record! \ + "sha" => build.commit_sha, + "commit" => { "author" => { "name" => "Needle Committer" } }, + "author" => { "id" => 9001, "login" => "needle-committer", "avatar_url" => nil } + + data = listing(repository, q: "nEeDlE") + + assert_equal matches.map { _1.id.to_s }.sort, data.fetch("prototypes").pluck("id").sort + assert_equal 5, data.fetch("listing").fetch("matchingCount") + assert_equal 6, data.fetch("listing").fetch("totalCount") + end + + test "treats percent underscore and backslash as literal search characters" do + repository, user = sign_in_repository + literal = registered_prototype(repository, author: user, name: '100%_done\path') + registered_prototype(repository, author: user, name: "100XXdonepath") + + [ "%", "_", "\\" ].each do |query| + data = listing(repository, q: query) + assert_equal [ literal.id.to_s ], data.fetch("prototypes").pluck("id"), query + end + end + + test "author filters and card attribution use the saved registrant rather than the commit author" do + repository, user = sign_in_repository + other = create_author("registrant", name: "Registration Author", avatar_url: "https://avatars.example.test/registrant") + mine = registered_prototype(repository, author: user) + theirs = registered_prototype(repository, author: other) + unknown = registered_prototype(repository) + [ theirs, unknown ].each do |prototype| + build = builds.create(prototype:) + build.commit_author.record! \ + "sha" => build.commit_sha, + "commit" => { "author" => { "name" => "Octocat" } }, + "author" => { "id" => user.github_id, "login" => user.login, "avatar_url" => user.avatar_url } + end + + all = listing(repository) + cards = all.fetch("prototypes").index_by { _1.fetch("id") } + author = cards.fetch(theirs.id.to_s).fetch("author") + assert_equal "@registrant", author.fetch("ownerLabel") + assert_equal "https://github.com/registrant", author.fetch("url") + assert_equal other.avatar_url, author.fetch("avatarUrl") + unknown_author = cards.fetch(unknown.id.to_s).fetch("author") + assert_equal I18n.t("repositories.show.unknown_author"), unknown_author.fetch("ownerLabel") + assert_nil unknown_author.fetch("url") + assert_nil unknown_author.fetch("avatarUrl") + + me = listing(repository, author: "me") + assert_equal [ mine.id.to_s ], me.fetch("prototypes").pluck("id") + assert_equal "me", me.fetch("listing").fetch("author") + assert_equal 1, me.fetch("listing").fetch("matchingCount") + assert_equal 3, me.fetch("listing").fetch("totalCount") + named = listing(repository, author: other.id.to_s) + assert_equal [ theirs.id.to_s ], named.fetch("prototypes").pluck("id") + assert_equal other.id.to_s, named.fetch("listing").fetch("author") + assert_includes named.fetch("listing").fetch("authorLabel"), other.login + assert_equal 3, all.fetch("prototypes").size + end + + test "a stale valid cursor returns an empty filtered page with a route back to newest" do + repository, user = sign_in_repository + originals = 25.times.map { registered_prototype(repository, author: user, name: "Needle") } + first = listing(repository, q: "Needle", author: "me") + originals.first.update!(branch_deleted_at: Time.current) + + stale = listing_at(first.fetch("listing").fetch("nextPath")) + + assert_empty stale.fetch("prototypes") + assert_equal 24, stale.fetch("listing").fetch("matchingCount") + assert_equal 24, stale.fetch("listing").fetch("totalCount") + newest_path = stale.fetch("listing").fetch("newestPath") + assert_filters newest_path, q: "Needle", author: "me" + newest = listing_at(newest_path) + assert_equal 24, newest.fetch("prototypes").size + assert_nil newest.fetch("listing").fetch("nextPath") + end + + test "rejects malformed filters and cursors instead of silently returning an unfiltered collection" do + repository, user = sign_in_repository + registered_prototype(repository, author: user) + + [ + { q: "x" * 201 }, { q: [ "query" ] }, { q: { value: "query" } }, + { author: [ "me" ] }, { author: { id: user.id } }, { author: "0" }, + { author: "-1" }, { author: "1.5" }, { author: "#{user.id}suffix" }, + { author: "someone" }, { cursor: "not-a-signed-cursor" }, { cursor: [ "cursor" ] } + ].each do |params| + get repository_path(repository.github_id), params:, headers: json_headers + assert_response :bad_request, params.inspect + end + + accepted = listing(repository, q: "x" * 200) + assert_equal "x" * 200, accepted.fetch("listing").fetch("query") + assert_empty accepted.fetch("prototypes") + end + + test "prototype cursors cannot be tampered with or reused for another repository filter or user" do + repository, user = sign_in_repository + 25.times { registered_prototype(repository, author: user, name: "Needle") } + first = listing(repository, q: "Needle", author: "me") + path = first.fetch("listing").fetch("nextPath") + params = Rack::Utils.parse_query(URI.parse(path).query) + cursor_key = (params.keys - %w[q author]).sole + other_repository = repositories.create(github_id: 2002, name_with_owner: "github/other") + stub_github_permissions([ repository_payload(2002, "github/other") ], { 2002 => "pull" }) + + [ + [ repository, params.merge(cursor_key => "#{params.fetch(cursor_key)}tampered") ], + [ repository, params.merge("q" => "Different") ], + [ repository, params.merge("author" => user.id.to_s) ], + [ other_repository, params ] + ].each do |target, query| + get repository_path(target.github_id), params: query, headers: json_headers + assert_response :bad_request + end + + sign_in_as_another_reader + get path, headers: json_headers + assert_response :bad_request + end + + test "author lookup is distinct bounded and includes registrants outside the loaded prototype page" do + repository, user = sign_in_repository + time = Time.current + authors = 22.times.map do |index| + login = index < 2 ? "contributor-00" : "contributor-#{index.to_s.rjust(2, '0')}" + author = create_author(login, name: index.zero? ? nil : "Contributor #{index}") + registered_prototype(repository, author:, at: time - 1.day) + author + end + 25.times { registered_prototype(repository, author: authors.first, at: time) } + registered_prototype(repository, author: user, at: time) + registered_prototype(repository, at: time) + first = author_lookup(repository) + expected = authors.sort_by { [ _1.login, _1.id ] } + assert_equal expected.first(20).map { _1.id.to_s }, first.fetch("authors").pluck("id") + expected_author = { + "id" => authors.first.id.to_s, "login" => authors.first.login, "name" => nil, "avatarUrl" => nil + } + assert_equal expected_author, first.fetch("authors").first + assert_kind_of String, first.fetch("nextCursor") + + second = author_lookup(repository, cursor: first.fetch("nextCursor")) + assert_equal expected.last(2).map { _1.id.to_s }, second.fetch("authors").pluck("id") + assert_nil second.fetch("nextCursor") + assert_equal 22, (first.fetch("authors") + second.fetch("authors")).pluck("id").uniq.size + end + + test "author lookup searches literal login and name substrings within eligible repository registrants only" do + repository, user = sign_in_repository + by_login = create_author("needle-login") + by_name = create_author("another-login", name: "A NEEDLE person") + literal = create_author("literal-owner", name: "100%_Team") + [ by_login, by_name, literal ].each { registered_prototype(repository, author: _1) } + registered_prototype(repository, author: user) + registered_prototype(repository) + hidden = create_author("needle-hidden") + prototypes.create(repository:, branch_name: "prototype/unregistered", author: hidden) + prototypes.create(repository:, branch_name: "replica/install-workflow", author: hidden) + prototypes.create(repository:, branch_name: repository.default_branch, author: hidden) + retired = registered_prototype(repository, author: hidden) + retired.update!(branch_deleted_at: Time.current) + foreign = repositories.create(github_id: 2002, name_with_owner: "github/other") + stub_github_permissions([ repository_payload(2002, "github/other") ], { 2002 => "pull" }) + registered_prototype(foreign, author: hidden) + distractor = create_author("distractor", name: "100XXTeam") + registered_prototype(repository, author: distractor) + + result = author_lookup(repository, q: "nEeDlE") + assert_equal [ by_login.id.to_s, by_name.id.to_s ].sort, result.fetch("authors").pluck("id").sort + [ "%", "_" ].each do |query| + assert_equal [ literal.id.to_s ], author_lookup(repository, q: query).fetch("authors").pluck("id") + end + assert_equal [ by_login, by_name, literal, distractor ].map { _1.id.to_s }.sort, + author_lookup(repository).fetch("authors").pluck("id").sort + end + + test "author continuations remain stable when an author is inserted before the cursor" do + repository, = sign_in_repository + authors = 21.times.map do |index| + author = create_author("owner-#{index.to_s.rjust(2, '0')}") + registered_prototype(repository, author:) + author + end + first = author_lookup(repository) + registered_prototype(repository, author: create_author("aaa-new-owner")) + + second = author_lookup(repository, cursor: first.fetch("nextCursor")) + + assert_equal [ authors.last.id.to_s ], second.fetch("authors").pluck("id") + assert_nil second.fetch("nextCursor") + end + + test "author lookup validates query shapes length and signed cursor scope" do + repository, = sign_in_repository + 21.times do |index| + registered_prototype(repository, author: create_author("needle-#{index.to_s.rjust(2, '0')}")) + end + other_repository = repositories.create(github_id: 2002, name_with_owner: "github/other") + stub_github_permissions([ repository_payload(2002, "github/other") ], { 2002 => "pull" }) + first = author_lookup(repository, q: "needle") + cursor = first.fetch("nextCursor") + assert_kind_of String, cursor + + [ + { q: "x" * 201 }, { q: [ "needle" ] }, { q: { value: "needle" } }, + { cursor: [ cursor ] }, { cursor: "invalid" }, + { cursor: "#{cursor}tampered", q: "needle" }, { cursor:, q: "another" } + ].each do |params| + get authors_path(repository), params:, headers: json_headers + assert_response :bad_request, params.inspect + end + get authors_path(other_repository), params: { cursor:, q: "needle" }, headers: json_headers + assert_response :bad_request + assert_empty author_lookup(repository, q: "x" * 200).fetch("authors") + + sign_in_as_another_reader + get authors_path(repository), params: { cursor:, q: "needle" }, headers: json_headers + assert_response :bad_request + end + + test "both listing endpoints require a signed in repository reader and fail closed on permission errors" do + visible = repository_payload(2001, "github/prototype") + denied = repository_payload(2002, "github/private") + failing = repository_payload(2003, "github/unavailable") + stub_github_sign_in \ + repositories: [ visible, denied, failing ], + permissions: { 2001 => "pull", 2002 => nil, 2003 => :failure } + sign_in_to_hub + [ [ 2002, :not_found ], [ 2003, :service_unavailable ] ].each do |github_id, status| + [ "/repositories/#{github_id}", "/repositories/#{github_id}/prototype_authors" ].each do |path| + get path, headers: json_headers + assert_response status + end + end + + delete session_path + [ "/repositories/2001", "/repositories/2001/prototype_authors" ].each do |path| + get path, headers: json_headers + assert_redirected_to root_path + end + end + + test "local cards retain the local identity while author filtering uses saved registration authors" do + root = Rails.root.join("storage", "listing-local-test-#{SecureRandom.hex(6)}") + checkout = root.join("one") + original_enabled = LocalDevelopment.method(:enabled?) + original_root = LocalDevelopment.method(:repositories_root) + LocalDevelopment.define_singleton_method(:enabled?) { true } + LocalDevelopment.define_singleton_method(:repositories_root) { root } + FileUtils.mkdir_p(checkout) + [ + [ "init", "-b", "main" ], + [ "-c", "user.name=Local Test", "-c", "user.email=local@example.invalid", + "commit", "--allow-empty", "-m", "Initial" ] + ].each do |arguments| + _, error, status = Open3.capture3("git", "-C", checkout.to_s, *arguments) + assert status.success?, error + end + host! "replica.localhost:19500" + post local_authentication_path + assert_redirected_to welcome_path + repository = Repository.find_by!(local_path: checkout.to_s) + user = User.find_by!(github_id: nil) + other = create_author("saved-registrant") + mine = registered_prototype(repository, author: user) + theirs = registered_prototype(repository, author: other) + unknown = registered_prototype(repository) + + all = listing(repository) + assert_equal [ mine, theirs, unknown ].map { _1.id.to_s }.sort, + all.fetch("prototypes").pluck("id").sort + all.fetch("prototypes").each do |card| + assert_equal "@#{user.login}", card.fetch("author").fetch("ownerLabel") + assert_equal User::LOCAL_AVATAR_URL, card.fetch("author").fetch("avatarUrl") + assert_nil card.fetch("author").fetch("url") + end + assert_equal [ mine.id.to_s ], listing(repository, author: "me").fetch("prototypes").pluck("id") + assert_equal [ theirs.id.to_s ], + listing(repository, author: other.id.to_s).fetch("prototypes").pluck("id") + assert_equal [ other.id.to_s ], author_lookup(repository).fetch("authors").pluck("id") + ensure + LocalDevelopment.define_singleton_method(:enabled?, original_enabled) if original_enabled + LocalDevelopment.define_singleton_method(:repositories_root, original_root) if original_root + FileUtils.rm_rf(root) if root + end + + private + + def sign_in_repository + payload = repository_payload(2001, "github/prototype") + stub_github_sign_in(repositories: [ payload ], permissions: { 2001 => "pull" }) + sign_in_to_hub + [ Repository.find_by!(github_id: 2001), User.find_by!(github_id: 1001) ] + end + + def sign_in_as_another_reader + delete session_path + stub_github_oauth_user(github_id: 1002, login: "another-reader") + payloads = Repository.where.not(github_id: nil).map { repository_payload(_1.github_id, _1.name_with_owner) } + stub_github_permissions \ + payloads, + payloads.to_h { [ _1.fetch("id"), "pull" ] }, + github_id: 1002, + login: "another-reader" + sign_in_to_hub(github_id: 1002) + end + + def create_author(login, name: nil, avatar_url: nil) + users.create(github_id: User.maximum(:github_id).to_i + 1, login:, name:, avatar_url:) + end + + def registered_prototype(repository, author: nil, name: "Prototype", description: nil, + branch_name: "prototype/#{SecureRandom.hex(6)}", at: Time.current) + prototype = prototypes.create(repository:, branch_name:) + travel_to(at) { prototype.register!(name:, description:, author:) } + prototype + end + + def listing(repository, **params) + listing_at(repository_path(repository.route_id), params:) + end + + def listing_at(path, params: {}) + assert_kind_of String, path + get path, params:, headers: json_headers + assert_response :success + JSON.parse(response.body).fetch("data") + end + + def authors_path(repository) + "/repositories/#{repository.route_id}/prototype_authors" + end + + def author_lookup(repository, **params) + get authors_path(repository), params:, headers: json_headers + assert_response :success + JSON.parse(response.body) + end + + def assert_filters(path, q:, author:) + assert_kind_of String, path + params = Rack::Utils.parse_query(URI.parse(path).query) + assert_equal q, params.fetch("q") + assert_equal author, params.fetch("author") + end + + def json_headers + { "Accept" => "application/json" } + end +end From 370b350d83a06c074e462946458f6326a72a92a0 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:02:46 +0000 Subject: [PATCH 02/11] fix(hub): use standard filter menu and pagination buttons Replace the custom author popup with Primer Brand ActionMenu selection rows while retaining bounded search and Me-first ordering. Style page navigation as secondary buttons without changing link behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../prototype-author-filter/index.test.tsx | 51 ++++- .../prototype-author-filter/index.tsx | 206 ++---------------- .../prototype-author-filter/options/index.tsx | 175 +++++++++++++++ .../prototype-author-filter/styles.css | 53 +++-- .../pages/repositories-show-page/index.tsx | 17 +- .../pages/repositories-show-page/styles.css | 15 +- 6 files changed, 279 insertions(+), 238 deletions(-) create mode 100644 hub/app/javascript/features/prototype-author-filter/options/index.tsx diff --git a/hub/app/javascript/features/prototype-author-filter/index.test.tsx b/hub/app/javascript/features/prototype-author-filter/index.test.tsx index 1c0c0eb0..074d1a1c 100644 --- a/hub/app/javascript/features/prototype-author-filter/index.test.tsx +++ b/hub/app/javascript/features/prototype-author-filter/index.test.tsx @@ -22,13 +22,18 @@ describe("PrototypeAuthorFilter()", () => { ) const trigger = screen.getByRole("button", { name: "Author: All authors" }) fireEvent.click(trigger) - const dialog = screen.getByRole("dialog", { name: "Author" }) - expect(within(dialog).getAllByRole("button")[0]).toHaveTextContent("Me") - expect(within(dialog).getByRole("searchbox")).toHaveFocus() - expect(await within(dialog).findByRole("button", { name: "@mona (Mona)" })).toBeInTheDocument() - fireEvent.click(within(dialog).getByRole("button", { name: "Me" })) + const menu = screen.getByRole("menu", { name: "Author" }) + expect(within(menu).getAllByRole("menuitemradio")[0]).toHaveTextContent("Me") + expect(within(menu).getByRole("searchbox")).toHaveFocus() + expect( + await within(menu).findByRole("menuitemradio", { name: "@mona (Mona)" }), + ).toBeInTheDocument() + expect( + within(menu).getByRole("menuitemradio", { name: "All authors", checked: true }), + ).toBeInTheDocument() + fireEvent.click(within(menu).getByRole("menuitemradio", { name: "Me" })) expect(onChange).toHaveBeenCalledExactlyOnceWith("me") - expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + expect(screen.queryByRole("menu")).not.toBeInTheDocument() expect(trigger).toHaveFocus() expect(screen.queryByText("My prototypes")).not.toBeInTheDocument() }) @@ -55,18 +60,18 @@ describe("PrototypeAuthorFilter()", () => { , ) fireEvent.click(screen.getByRole("button", { name: "Author: All authors" })) - expect(await screen.findByRole("button", { name: "@octocat" })).toBeInTheDocument() + expect(await screen.findByRole("menuitemradio", { name: "@octocat" })).toBeInTheDocument() const firstSignal = onSearch.mock.calls[0]?.[2] fireEvent.click(screen.getByRole("button", { name: "Next authors" })) - expect(await screen.findByRole("button", { name: "@mona" })).toBeInTheDocument() - expect(screen.queryByRole("button", { name: "@octocat" })).not.toBeInTheDocument() + expect(await screen.findByRole("menuitemradio", { name: "@mona" })).toBeInTheDocument() + expect(screen.queryByRole("menuitemradio", { name: "@octocat" })).not.toBeInTheDocument() expect(firstSignal?.aborted).toBe(true) fireEvent.change(screen.getByRole("searchbox"), { target: { value: "mona" } }) await waitFor(() => expect(onSearch).toHaveBeenLastCalledWith("mona", null, expect.any(AbortSignal)), ) fireEvent.keyDown(screen.getByRole("searchbox"), { key: "Escape" }) - expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + expect(screen.queryByRole("menu")).not.toBeInTheDocument() expect(onSearch.mock.calls.at(-1)?.[2].aborted).toBe(true) }) @@ -79,9 +84,33 @@ describe("PrototypeAuthorFilter()", () => { ) fireEvent.click(screen.getByRole("button", { name: "Author: All authors" })) expect(await screen.findByRole("alert")).toHaveTextContent(prototypeListing.copy.authorsFailed) - expect(screen.getByRole("button", { name: "Me" })).toBeInTheDocument() + expect(screen.getByRole("menuitemradio", { name: "Me" })).toBeInTheDocument() fireEvent.click(screen.getByRole("button", { name: "Retry" })) expect(await screen.findByRole("status")).toHaveTextContent(prototypeListing.copy.loading) await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("No matching authors")) }) + + it.each([ + ["Me", " ", "me"], + ["All authors", "Enter", ""], + ])("selects %s once with the keyboard and restores trigger focus", (name, key, value) => { + const onChange = vi.fn() + render( + Promise.resolve({ authors: [], nextCursor: null })} + />, + ) + const trigger = screen.getByRole("button", { name: "Author: All authors" }) + fireEvent.click(trigger) + fireEvent.keyDown(screen.getByRole("searchbox"), { key: "ArrowDown" }) + expect(screen.getByRole("menuitemradio", { name: "Me" })).toHaveFocus() + const option = screen.getByRole("menuitemradio", { name }) + option.focus() + fireEvent.keyDown(option, { key }) + expect(onChange).toHaveBeenCalledExactlyOnceWith(value) + expect(screen.queryByRole("menu")).not.toBeInTheDocument() + expect(trigger).toHaveFocus() + }) }) diff --git a/hub/app/javascript/features/prototype-author-filter/index.tsx b/hub/app/javascript/features/prototype-author-filter/index.tsx index f7f6cb40..20bae721 100644 --- a/hub/app/javascript/features/prototype-author-filter/index.tsx +++ b/hub/app/javascript/features/prototype-author-filter/index.tsx @@ -1,13 +1,12 @@ -import { useEffect, useId, useRef, useState } from "react" - -import { CheckIcon, ChevronDownIcon } from "@primer/octicons-react" -import { Button, TextInput } from "@primer/react-brand" +import { ActionMenu } from "@primer/react-brand" import type { PrototypeAuthorResults, PrototypeListing } from "@utils/prototype-listing/types" +import { PrototypeAuthorOptions } from "./options" + import "./styles.css" -/** Offers Me first and searches bounded pages of this repository's registration authors. */ +/** Offers a searchable, single-selection menu of saved registration authors. */ export function PrototypeAuthorFilter({ listing, onChange, @@ -21,195 +20,18 @@ export function PrototypeAuthorFilter({ signal: AbortSignal, ) => Promise }) { - const [open, setOpen] = useState(false) - const [query, setQuery] = useState("") - const [cursor, setCursor] = useState(null) - const [results, setResults] = useState(null) - const [failed, setFailed] = useState(false) - const [retry, setRetry] = useState(0) - const wrapper = useRef(null) - const input = useRef(null) - const trigger = useRef(null) - const id = useId() - const copy = listing.copy - - useEffect(() => { - if (!open) return - - input.current?.focus() - function dismiss(event: PointerEvent) { - if (event.target instanceof Node && !wrapper.current?.contains(event.target)) setOpen(false) - } - document.addEventListener("pointerdown", dismiss) - return () => document.removeEventListener("pointerdown", dismiss) - }, [open]) - - useEffect(() => { - if (!open) return - - const controller = new AbortController() - const timer = setTimeout(() => { - onSearch(query, cursor, controller.signal).then( - (value) => { - if (!controller.signal.aborted) setResults(value) - }, - () => { - if (!controller.signal.aborted) setFailed(true) - }, - ) - }, 200) - return () => { - clearTimeout(timer) - controller.abort() - } - }, [open, query, cursor, retry, onSearch]) - - function select(value: string) { - setOpen(false) - trigger.current?.focus() - onChange(value) - } + const label = `${listing.copy.author}: ${listing.authorLabel}` return ( -
{ - if (event.key === "Escape" && open) { - event.preventDefault() - event.stopPropagation() - setOpen(false) - trigger.current?.focus() - } - }} - onBlur={(event) => { - if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false) - }} - > - - - {open ? ( - - ) : null} +
+ + + {label} + + + + +
) } diff --git a/hub/app/javascript/features/prototype-author-filter/options/index.tsx b/hub/app/javascript/features/prototype-author-filter/options/index.tsx new file mode 100644 index 00000000..51d0c43b --- /dev/null +++ b/hub/app/javascript/features/prototype-author-filter/options/index.tsx @@ -0,0 +1,175 @@ +import { useEffect, useRef, useState } from "react" +import type { KeyboardEvent } from "react" + +import { ActionMenu, Button, TextInput } from "@primer/react-brand" + +import type { PrototypeAuthorResults, PrototypeListing } from "@utils/prototype-listing/types" + +/** Loads author choices only while the menu is mounted and cancels stale searches. */ +export function PrototypeAuthorOptions({ + listing, + onSearch, + handler, +}: { + listing: PrototypeListing + onSearch: ( + query: string, + cursor: string | null, + signal: AbortSignal, + ) => Promise + handler?: (author: string) => void +}) { + const [query, setQuery] = useState("") + const [cursor, setCursor] = useState(null) + const [results, setResults] = useState(null) + const [failed, setFailed] = useState(false) + const [retry, setRetry] = useState(0) + const input = useRef(null) + const copy = listing.copy + + useEffect(() => { + input.current?.focus() + }, []) + + useEffect(() => { + const controller = new AbortController() + const timer = setTimeout(() => { + onSearch(query, cursor, controller.signal).then( + (value) => { + if (!controller.signal.aborted) setResults(value) + }, + () => { + if (!controller.signal.aborted) setFailed(true) + }, + ) + }, 200) + return () => { + clearTimeout(timer) + controller.abort() + } + }, [query, cursor, retry, onSearch]) + + function selectOnKeyDown(event: KeyboardEvent) { + if (event.key !== " " && event.key !== "Enter") return + + event.preventDefault() + event.stopPropagation() + event.currentTarget.click() + } + + const choiceProps = { handler, type: "single" as const, onKeyDownCapture: selectOnKeyDown } + + return ( + <> +
  • { + if (event.target === event.currentTarget) input.current?.focus() + }} + > + { + if (event.key === "Enter" || event.key === " ") event.stopPropagation() + if (event.key === "ArrowDown") { + event.preventDefault() + event.stopPropagation() + event.currentTarget + .closest('[role="menu"]') + ?.querySelector('[role="menuitemradio"]') + ?.focus() + } + }} + onChange={(event) => { + setQuery(event.target.value) + setCursor(null) + setResults(null) + setFailed(false) + }} + /> +
  • + + + {copy.me} + + + {copy.allAuthors} + + + {failed ? ( +
  • +
    +

    {copy.authorsFailed}

    + +
    +
  • + ) : !results || !results.authors.length ? ( +
  • +

    {results ? copy.noAuthors : copy.loading}

    +
  • + ) : ( + results.authors.map((author) => ( + + @{author.login} + {author.name ? ` (${author.name})` : ""} + + )) + )} + + {results?.nextCursor || cursor ? ( +
  • + {results?.nextCursor ? ( + + ) : null} + {cursor ? ( + + ) : null} +
  • + ) : null} + + ) +} diff --git a/hub/app/javascript/features/prototype-author-filter/styles.css b/hub/app/javascript/features/prototype-author-filter/styles.css index b4500e13..1c4dab45 100644 --- a/hub/app/javascript/features/prototype-author-filter/styles.css +++ b/hub/app/javascript/features/prototype-author-filter/styles.css @@ -1,30 +1,41 @@ .prototype-author-filter { - position: relative; + width: 16rem; + max-width: 100%; } -.prototype-author-filter__popup { - position: absolute; - z-index: 2; - top: calc(100% + var(--base-size-8)); - inset-inline-start: 0; - width: min(22rem, calc(100vw - 2rem)); - max-height: 28rem; - padding: var(--base-size-16); - overflow: auto; - border: var(--brand-borderWidth-thin) solid var(--hub-border-default); - border-radius: var(--hub-radius-medium); - background: var(--hub-canvas-default); - box-shadow: var(--hub-shadow-floating); +.prototype-author-filter [aria-haspopup], +.prototype-author-filter [role="menu"] { + width: 100%; + min-width: 0; } -.prototype-author-filter__choices, -.prototype-author-filter__choices > div { - display: flex; - flex-direction: column; - align-items: stretch; - gap: var(--base-size-4); +.prototype-author-filter [role="menuitemradio"] { + overflow-wrap: anywhere; +} + +.prototype-author-filter__search { + margin-bottom: var(--base-size-8); +} + +.prototype-author-filter__search, +.prototype-author-filter__status, +.prototype-author-filter__paging { + list-style: none; } -.prototype-author-filter__choices { +.prototype-author-filter__status { + padding: var(--base-size-8); +} + +.prototype-author-filter__paging { + display: flex; + flex-wrap: wrap; + gap: var(--base-size-8); margin-top: var(--base-size-8); } + +@media (prefers-reduced-motion: reduce) { + .prototype-author-filter [role="menu"] { + animation: none; + } +} diff --git a/hub/app/javascript/pages/repositories-show-page/index.tsx b/hub/app/javascript/pages/repositories-show-page/index.tsx index d09c8b00..d282735b 100644 --- a/hub/app/javascript/pages/repositories-show-page/index.tsx +++ b/hub/app/javascript/pages/repositories-show-page/index.tsx @@ -1,7 +1,8 @@ import { useState } from "react" +import type { MouseEvent } from "react" import { GearIcon, GitBranchIcon, SearchIcon } from "@primer/octicons-react" -import { Button, InlineLink, Text } from "@primer/react-brand" +import { Button, Text } from "@primer/react-brand" import { HubAlert } from "@components/hub-alert" import { HubEmptyState } from "@components/hub-empty-state" @@ -264,11 +265,19 @@ export function RepositoriesShowPage({ ] as const ).map(([path, label]) => path ? ( - { + onClick={(event: MouseEvent) => { if ( event.button === 0 && !event.metaKey && @@ -283,7 +292,7 @@ export function RepositoriesShowPage({ }} > {label} - + ) : null, )} diff --git a/hub/app/javascript/pages/repositories-show-page/styles.css b/hub/app/javascript/pages/repositories-show-page/styles.css index 002ece25..86c4151c 100644 --- a/hub/app/javascript/pages/repositories-show-page/styles.css +++ b/hub/app/javascript/pages/repositories-show-page/styles.css @@ -14,11 +14,15 @@ .repository-detail__pagination { display: flex; - justify-content: space-between; + justify-content: flex-end; flex-wrap: wrap; gap: var(--hub-space-16); } +.repository-detail__previous-page { + margin-inline-end: auto; +} + .repository-detail__count { color: var(--hub-fg-muted); } @@ -127,15 +131,6 @@ } .repository-detail__toolbar { - position: relative; justify-content: flex-end; } - - .repository-detail__toolbar .prototype-author-filter { - position: static; - } - - .repository-detail__toolbar .prototype-author-filter__popup { - width: min(22rem, 100%); - } } From 36f9fe0c0831282af4988b13b954c014ab6ca2d2 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:38:52 +0000 Subject: [PATCH 03/11] test(hub): trim redundant gallery regression cases Consolidate insertion stability into the prototype and author paging scenarios, share cursor-scope checks, and rely on existing local-identity coverage. Combine overlapping frontend filter flows and remove repeated assertions without changing application code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../prototype-author-filter/index.test.tsx | 71 ++---- .../use-prototype-listing/index.test.tsx | 21 +- .../repositories-show-page/index.test.tsx | 71 +----- .../integration/prototype_listing_test.rb | 222 ++++-------------- 4 files changed, 80 insertions(+), 305 deletions(-) diff --git a/hub/app/javascript/features/prototype-author-filter/index.test.tsx b/hub/app/javascript/features/prototype-author-filter/index.test.tsx index 074d1a1c..497870fd 100644 --- a/hub/app/javascript/features/prototype-author-filter/index.test.tsx +++ b/hub/app/javascript/features/prototype-author-filter/index.test.tsx @@ -2,22 +2,15 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea import { describe, expect, it, vi } from "vitest" import { prototypeListing } from "@/storybook/fixtures/prototype-listing" -import type { PrototypeAuthorResults } from "@utils/prototype-listing/types" +import type { PrototypeListingControls } from "@utils/prototype-listing/types" import { PrototypeAuthorFilter } from "." describe("PrototypeAuthorFilter()", () => { - it("pins Me first and selects the authenticated-user filter without a separate personal view", async () => { + it("pins Me first and supports selecting and clearing an author with the keyboard", () => { const onChange = vi.fn() - const onSearch = vi.fn< - (query: string, cursor: string | null, signal: AbortSignal) => Promise - >(() => - Promise.resolve({ - authors: [{ id: "2", login: "mona", name: "Mona", avatarUrl: null }], - nextCursor: null, - }), - ) - render( + const onSearch = vi.fn(() => Promise.resolve({ authors: [], nextCursor: null })) + const { rerender } = render( , ) const trigger = screen.getByRole("button", { name: "Author: All authors" }) @@ -25,28 +18,36 @@ describe("PrototypeAuthorFilter()", () => { const menu = screen.getByRole("menu", { name: "Author" }) expect(within(menu).getAllByRole("menuitemradio")[0]).toHaveTextContent("Me") expect(within(menu).getByRole("searchbox")).toHaveFocus() - expect( - await within(menu).findByRole("menuitemradio", { name: "@mona (Mona)" }), - ).toBeInTheDocument() expect( within(menu).getByRole("menuitemradio", { name: "All authors", checked: true }), ).toBeInTheDocument() - fireEvent.click(within(menu).getByRole("menuitemradio", { name: "Me" })) + fireEvent.keyDown(within(menu).getByRole("searchbox"), { key: "ArrowDown" }) + const me = within(menu).getByRole("menuitemradio", { name: "Me" }) + expect(me).toHaveFocus() + fireEvent.keyDown(me, { key: " " }) expect(onChange).toHaveBeenCalledExactlyOnceWith("me") expect(screen.queryByRole("menu")).not.toBeInTheDocument() expect(trigger).toHaveFocus() - expect(screen.queryByText("My prototypes")).not.toBeInTheDocument() + rerender( + , + ) + fireEvent.click(screen.getByRole("button", { name: "Author: Me" })) + const all = screen.getByRole("menuitemradio", { name: "All authors", checked: false }) + all.focus() + fireEvent.keyDown(all, { key: "Enter" }) + expect(onChange).toHaveBeenCalledTimes(2) + expect(onChange).toHaveBeenLastCalledWith("") + expect(screen.queryByRole("menu")).not.toBeInTheDocument() + expect(trigger).toHaveFocus() }) it("replaces author option pages and cancels stale searches when the query changes", async () => { const onSearch = vi - .fn< - ( - query: string, - cursor: string | null, - signal: AbortSignal, - ) => Promise - >(() => + .fn(() => Promise.resolve({ authors: [{ id: "2", login: "mona", name: null, avatarUrl: null }], nextCursor: "next", @@ -89,28 +90,4 @@ describe("PrototypeAuthorFilter()", () => { expect(await screen.findByRole("status")).toHaveTextContent(prototypeListing.copy.loading) await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("No matching authors")) }) - - it.each([ - ["Me", " ", "me"], - ["All authors", "Enter", ""], - ])("selects %s once with the keyboard and restores trigger focus", (name, key, value) => { - const onChange = vi.fn() - render( - Promise.resolve({ authors: [], nextCursor: null })} - />, - ) - const trigger = screen.getByRole("button", { name: "Author: All authors" }) - fireEvent.click(trigger) - fireEvent.keyDown(screen.getByRole("searchbox"), { key: "ArrowDown" }) - expect(screen.getByRole("menuitemradio", { name: "Me" })).toHaveFocus() - const option = screen.getByRole("menuitemradio", { name }) - option.focus() - fireEvent.keyDown(option, { key }) - expect(onChange).toHaveBeenCalledExactlyOnceWith(value) - expect(screen.queryByRole("menu")).not.toBeInTheDocument() - expect(trigger).toHaveFocus() - }) }) diff --git a/hub/app/javascript/hooks/use-prototype-listing/index.test.tsx b/hub/app/javascript/hooks/use-prototype-listing/index.test.tsx index c26e0c4a..6cffa6e1 100644 --- a/hub/app/javascript/hooks/use-prototype-listing/index.test.tsx +++ b/hub/app/javascript/hooks/use-prototype-listing/index.test.tsx @@ -20,7 +20,7 @@ function navigation(visit = vi.fn(() => Promise.resolve(undefined))) { } describe("usePrototypeListing()", () => { - it("debounces server search while preserving the author and dropping the old cursor", async () => { + it("debounces search but immediately submits author changes and cancels pending edits on reset", async () => { vi.useFakeTimers() try { const visit = vi.fn(() => Promise.resolve(undefined)) @@ -32,7 +32,6 @@ describe("usePrototypeListing()", () => { result.current.onQueryChange("nav") result.current.onQueryChange(" navigation ") }) - expect(result.current.query).toBe(" navigation ") expect(visit).not.toHaveBeenCalled() await act(async () => vi.advanceTimersByTimeAsync(300)) expect(visit).toHaveBeenCalledExactlyOnceWith("/repositories/2001?q=navigation&author=me", { @@ -48,24 +47,12 @@ describe("usePrototypeListing()", () => { expect(visit).toHaveBeenLastCalledWith("/repositories/2001?q=immediate&author=me", { dataset: {}, }) - } finally { - vi.useRealTimers() - } - }) - - it("applies an author to the draft search immediately and clears pending search on reset", async () => { - vi.useFakeTimers() - try { - const visit = vi.fn(() => Promise.resolve(undefined)) - const { result } = renderHook(() => usePrototypeListing(prototypeListing), { - wrapper: navigation(visit), - }) act(() => result.current.onQueryChange("checkout")) await act(async () => { - result.current.onAuthorChange("me") + result.current.onAuthorChange("2") await Promise.resolve() }) - expect(visit).toHaveBeenLastCalledWith("/repositories/2001?q=checkout&author=me", { + expect(visit).toHaveBeenLastCalledWith("/repositories/2001?q=checkout&author=2", { dataset: {}, }) act(() => result.current.onQueryChange("next draft")) @@ -74,7 +61,7 @@ describe("usePrototypeListing()", () => { await Promise.resolve() }) await act(async () => vi.advanceTimersByTimeAsync(500)) - expect(visit).toHaveBeenCalledTimes(2) + expect(visit).toHaveBeenCalledTimes(4) expect(visit).toHaveBeenLastCalledWith("/repositories/2001", { dataset: {} }) expect(result.current.query).toBe("") } finally { diff --git a/hub/app/javascript/pages/repositories-show-page/index.test.tsx b/hub/app/javascript/pages/repositories-show-page/index.test.tsx index c04710f7..fd7bdf4c 100644 --- a/hub/app/javascript/pages/repositories-show-page/index.test.tsx +++ b/hub/app/javascript/pages/repositories-show-page/index.test.tsx @@ -335,7 +335,7 @@ describe("RepositoriesShowPage()", () => { ) }) - it("preserves newest-first server order in gallery, list, and search results", () => { + it("preserves server order in gallery and list views", () => { render( { "Z newest prototype", "A oldest prototype", ]) - - fireEvent.change(screen.getByRole("searchbox", { name: "Search prototypes" }), { - target: { value: "prototype" }, - }) - expect(prototypeLinks().map((link) => link.textContent)).toEqual([ - "Z newest prototype", - "A oldest prototype", - ]) - - fireEvent.click(screen.getByRole("radio", { name: "Gallery view" })) - expect(prototypeLinks().map((link) => link.textContent)).toEqual([ - "Z newest prototype", - "A oldest prototype", - ]) }) it("offers separate registration and personal dismissal forms for each recent branch", () => { @@ -528,62 +514,21 @@ describe("RepositoriesShowPage()", () => { ).toBeInTheDocument() }) - it("delegates search and renders server-filtered results and counts in both views", () => { - const onQueryChange = vi.fn() - const controls: PrototypeListingControls = { - query: "", - pending: false, - error: false, - onQueryChange, - onQuerySubmit: vi.fn(), - onAuthorChange: vi.fn(), - onClearFilters: vi.fn(), - onNavigate: vi.fn(), - onRetry: vi.fn(), - loadAuthors: vi.fn(() => Promise.resolve({ authors: [], nextCursor: null })), - } - const navigation = { - ...PAGE_DATA.prototypes[0]!, - id: "2", - name: "Navigation", - path: "/repositories/2001/prototypes/2", - } + it("delegates search without filtering server results again and renders empty results", () => { + const controls = listingControls({ query: "octocat" }) const { rerender } = render( , ) + expect(screen.getByRole("link", { name: "Welcome" })).toBeInTheDocument() const search = screen.getByRole("searchbox", { name: "Search prototypes" }) - fireEvent.change(search, { target: { value: "Navigation" } }) - expect(onQueryChange).toHaveBeenCalledWith("Navigation") + fireEvent.change(search, { target: { value: "missing" } }) + expect(controls.onQueryChange).toHaveBeenCalledWith("missing") fireEvent.keyDown(search, { key: "Enter" }) expect(controls.onQuerySubmit).toHaveBeenCalledOnce() - expect(screen.getByRole("link", { name: "Welcome" })).toBeInTheDocument() - rerender( - , - ) - expect(screen.getByText("1 prototype")).toBeInTheDocument() - expect(screen.getByRole("link", { name: "Navigation" })).toBeInTheDocument() - expect(screen.queryByRole("link", { name: "Welcome" })).not.toBeInTheDocument() - - fireEvent.click(screen.getByRole("radio", { name: "List view" })) - expect(screen.getAllByRole("row")).toHaveLength(2) - expect(screen.getByRole("link", { name: "Navigation" })).toBeInTheDocument() rerender( { expect(screen.queryByRole("link", { name: "Welcome" })).not.toBeInTheDocument() fireEvent.click(screen.getAllByRole("button", { name: "Clear search" })[0]!) - expect(onQueryChange).toHaveBeenLastCalledWith("") + expect(controls.onQueryChange).toHaveBeenLastCalledWith("") expect(search).toHaveFocus() }) diff --git a/hub/test/integration/prototype_listing_test.rb b/hub/test/integration/prototype_listing_test.rb index 20827984..92ed2b70 100644 --- a/hub/test/integration/prototype_listing_test.rb +++ b/hub/test/integration/prototype_listing_test.rb @@ -1,43 +1,11 @@ require "test_helper" -require "fileutils" -require "open3" class PrototypeListingTest < ActionDispatch::IntegrationTest setup do github_app_configurations.create_installed end - test "returns at most 24 registered reviewable prototypes in stable registration order" do - repository, user = sign_in_repository - time = Time.current.change(usec: 123456) - older = registered_prototype(repository, author: user, at: time - 1.day) - tied = 25.times.map { registered_prototype(repository, author: user, at: time) } - prototypes.create(repository:, branch_name: "prototype/unregistered", author: user) - prototypes.create(repository:, branch_name: repository.default_branch) - prototypes.create(repository:, branch_name: "replica/install-workflow") - retired = registered_prototype(repository, author: user) - retired.update!(branch_deleted_at: Time.current) - foreign = repositories.create(github_id: 2002, name_with_owner: "github/other") - stub_github_permissions([ repository_payload(2002, "github/other") ], { 2002 => "pull" }) - registered_prototype(foreign, author: user) - older.update!(description: "Edited after the newer registrations") - - data = listing(repository) - - assert_equal tied.reverse.first(24).map { _1.id.to_s }, data.fetch("prototypes").pluck("id") - metadata = data.fetch("listing") - assert_equal "", metadata.fetch("query") - assert_equal "", metadata.fetch("author") - assert_kind_of String, metadata.fetch("authorLabel") - assert_equal 26, metadata.fetch("matchingCount") - assert_equal 26, metadata.fetch("totalCount") - assert_nil metadata.fetch("previousPath") - assert_nil metadata.fetch("newestPath") - assert_kind_of String, metadata.fetch("nextPath") - assert_equal authors_path(repository), metadata.fetch("authorsPath") - end - - test "filters the full collection before limiting and preserves filters going next and previous" do + test "filters before paging and preserves stable continuations when a prototype is registered" do repository, user = sign_in_repository other = create_author("other-reviewer") time = Time.current @@ -52,43 +20,27 @@ class PrototypeListingTest < ActionDispatch::IntegrationTest assert_equal expected.first(24), first.fetch("prototypes").pluck("id") assert_equal 49, first.fetch("listing").fetch("matchingCount") assert_equal 75, first.fetch("listing").fetch("totalCount") + assert_nil first.fetch("listing").fetch("previousPath") + newest = registered_prototype(repository, author: user, name: "Needle added", at: time + 1.second) next_path = first.fetch("listing").fetch("nextPath") assert_filters next_path, q: "nEeDlE", author: user.id.to_s second = listing_at(next_path) assert_equal expected.slice(24, 24), second.fetch("prototypes").pluck("id") - assert_equal 49, second.fetch("listing").fetch("matchingCount") + assert_equal 50, second.fetch("listing").fetch("matchingCount") assert_filters second.fetch("listing").fetch("newestPath"), q: "nEeDlE", author: user.id.to_s previous_path = second.fetch("listing").fetch("previousPath") assert_filters previous_path, q: "nEeDlE", author: user.id.to_s previous = listing_at(previous_path) assert_equal expected.first(24), previous.fetch("prototypes").pluck("id") - assert_nil previous.fetch("listing").fetch("previousPath") - third = listing_at(second.fetch("listing").fetch("nextPath")) assert_equal expected.last(1), third.fetch("prototypes").pluck("id") assert_nil third.fetch("listing").fetch("nextPath") - assert_equal expected.first(24), + assert_equal [ newest.id.to_s, *expected ].first(24), listing_at(third.fetch("listing").fetch("newestPath")).fetch("prototypes").pluck("id") end - test "new registrations between next requests do not repeat visited prototypes" do - repository, user = sign_in_repository - time = Time.current - originals = 50.times.map { registered_prototype(repository, author: user, at: time) } - first = listing(repository) - registered_prototype(repository, author: user, at: time + 1.second) - - second = listing_at(first.fetch("listing").fetch("nextPath")) - third = listing_at(second.fetch("listing").fetch("nextPath")) - - visited = [ first, second, third ].flat_map { _1.fetch("prototypes").pluck("id") } - assert_equal originals.reverse.map { _1.id.to_s }, visited - assert_equal 51, second.fetch("listing").fetch("matchingCount") - assert_nil third.fetch("listing").fetch("nextPath") - end - - test "hydrates only the selected page and its Builds rather than the full gallery" do + test "selects only eligible prototypes and bounds card and Build hydration to 24 results" do repository, user = sign_in_repository time = Time.current records = 60.times.map do |index| @@ -98,6 +50,14 @@ class PrototypeListingTest < ActionDispatch::IntegrationTest prototype.update!(current_build: latest) if index.even? prototype end + prototypes.create(repository:, branch_name: "prototype/unregistered", author: user) + prototypes.create(repository:, branch_name: repository.default_branch) + prototypes.create(repository:, branch_name: "replica/install-workflow") + retired = registered_prototype(repository, author: user) + retired.update!(branch_deleted_at: Time.current) + foreign = repositories.create(github_id: 2002, name_with_owner: "github/other") + stub_github_permissions([ repository_payload(2002, "github/other") ], { 2002 => "pull" }) + registered_prototype(foreign, author: user) instantiated = Hash.new(0) subscriber = lambda do |*arguments| payload = arguments.last @@ -141,8 +101,6 @@ class PrototypeListingTest < ActionDispatch::IntegrationTest data = listing(repository, q: "nEeDlE") assert_equal matches.map { _1.id.to_s }.sort, data.fetch("prototypes").pluck("id").sort - assert_equal 5, data.fetch("listing").fetch("matchingCount") - assert_equal 6, data.fetch("listing").fetch("totalCount") end test "treats percent underscore and backslash as literal search characters" do @@ -158,7 +116,7 @@ class PrototypeListingTest < ActionDispatch::IntegrationTest test "author filters and card attribution use the saved registrant rather than the commit author" do repository, user = sign_in_repository - other = create_author("registrant", name: "Registration Author", avatar_url: "https://avatars.example.test/registrant") + other = create_author("registrant") mine = registered_prototype(repository, author: user) theirs = registered_prototype(repository, author: other) unknown = registered_prototype(repository) @@ -175,22 +133,15 @@ class PrototypeListingTest < ActionDispatch::IntegrationTest author = cards.fetch(theirs.id.to_s).fetch("author") assert_equal "@registrant", author.fetch("ownerLabel") assert_equal "https://github.com/registrant", author.fetch("url") - assert_equal other.avatar_url, author.fetch("avatarUrl") unknown_author = cards.fetch(unknown.id.to_s).fetch("author") assert_equal I18n.t("repositories.show.unknown_author"), unknown_author.fetch("ownerLabel") assert_nil unknown_author.fetch("url") - assert_nil unknown_author.fetch("avatarUrl") me = listing(repository, author: "me") assert_equal [ mine.id.to_s ], me.fetch("prototypes").pluck("id") - assert_equal "me", me.fetch("listing").fetch("author") - assert_equal 1, me.fetch("listing").fetch("matchingCount") - assert_equal 3, me.fetch("listing").fetch("totalCount") named = listing(repository, author: other.id.to_s) assert_equal [ theirs.id.to_s ], named.fetch("prototypes").pluck("id") - assert_equal other.id.to_s, named.fetch("listing").fetch("author") assert_includes named.fetch("listing").fetch("authorLabel"), other.login - assert_equal 3, all.fetch("prototypes").size end test "a stale valid cursor returns an empty filtered page with a route back to newest" do @@ -216,10 +167,8 @@ class PrototypeListingTest < ActionDispatch::IntegrationTest registered_prototype(repository, author: user) [ - { q: "x" * 201 }, { q: [ "query" ] }, { q: { value: "query" } }, - { author: [ "me" ] }, { author: { id: user.id } }, { author: "0" }, - { author: "-1" }, { author: "1.5" }, { author: "#{user.id}suffix" }, - { author: "someone" }, { cursor: "not-a-signed-cursor" }, { cursor: [ "cursor" ] } + { q: "x" * 201 }, { q: [ "query" ] }, { author: "0" }, + { cursor: "not-a-signed-cursor" }, { cursor: [ "cursor" ] } ].each do |params| get repository_path(repository.github_id), params:, headers: json_headers assert_response :bad_request, params.inspect @@ -227,35 +176,44 @@ class PrototypeListingTest < ActionDispatch::IntegrationTest accepted = listing(repository, q: "x" * 200) assert_equal "x" * 200, accepted.fetch("listing").fetch("query") - assert_empty accepted.fetch("prototypes") end - test "prototype cursors cannot be tampered with or reused for another repository filter or user" do + test "both cursor types reject tampering and changes to repository filter or viewer" do repository, user = sign_in_repository - 25.times { registered_prototype(repository, author: user, name: "Needle") } - first = listing(repository, q: "Needle", author: "me") + 25.times do |index| + registered_prototype(repository, author: create_author("needle-#{index}"), name: "Needle") + end + first = listing(repository, q: "Needle") path = first.fetch("listing").fetch("nextPath") params = Rack::Utils.parse_query(URI.parse(path).query) - cursor_key = (params.keys - %w[q author]).sole other_repository = repositories.create(github_id: 2002, name_with_owner: "github/other") stub_github_permissions([ repository_payload(2002, "github/other") ], { 2002 => "pull" }) + endpoints = [ + [ repository_path(repository.github_id), repository_path(other_repository.github_id), params ], + [ authors_path(repository), authors_path(other_repository), + { "q" => "needle", "cursor" => author_lookup(repository, q: "needle").fetch("nextCursor") } ] + ] - [ - [ repository, params.merge(cursor_key => "#{params.fetch(cursor_key)}tampered") ], - [ repository, params.merge("q" => "Different") ], - [ repository, params.merge("author" => user.id.to_s) ], - [ other_repository, params ] - ].each do |target, query| - get repository_path(target.github_id), params: query, headers: json_headers + endpoints.each do |endpoint, foreign_endpoint, query| + [ query.merge("cursor" => "#{query.fetch('cursor')}tampered"), + query.merge("q" => "Different") ].each do |invalid| + get endpoint, params: invalid, headers: json_headers + assert_response :bad_request + end + get foreign_endpoint, params: query, headers: json_headers assert_response :bad_request end + get repository_path(repository.github_id), params: params.merge("author" => user.id.to_s), headers: json_headers + assert_response :bad_request sign_in_as_another_reader - get path, headers: json_headers - assert_response :bad_request + endpoints.each do |endpoint, _, query| + get endpoint, params: query, headers: json_headers + assert_response :bad_request + end end - test "author lookup is distinct bounded and includes registrants outside the loaded prototype page" do + test "pages distinct registrants outside the loaded page without shifting for new authors" do repository, user = sign_in_repository time = Time.current authors = 22.times.map do |index| @@ -274,12 +232,10 @@ class PrototypeListingTest < ActionDispatch::IntegrationTest "id" => authors.first.id.to_s, "login" => authors.first.login, "name" => nil, "avatarUrl" => nil } assert_equal expected_author, first.fetch("authors").first - assert_kind_of String, first.fetch("nextCursor") - + registered_prototype(repository, author: create_author("aaa-new-owner")) second = author_lookup(repository, cursor: first.fetch("nextCursor")) assert_equal expected.last(2).map { _1.id.to_s }, second.fetch("authors").pluck("id") assert_nil second.fetch("nextCursor") - assert_equal 22, (first.fetch("authors") + second.fetch("authors")).pluck("id").uniq.size end test "author lookup searches literal login and name substrings within eligible repository registrants only" do @@ -311,50 +267,6 @@ class PrototypeListingTest < ActionDispatch::IntegrationTest author_lookup(repository).fetch("authors").pluck("id").sort end - test "author continuations remain stable when an author is inserted before the cursor" do - repository, = sign_in_repository - authors = 21.times.map do |index| - author = create_author("owner-#{index.to_s.rjust(2, '0')}") - registered_prototype(repository, author:) - author - end - first = author_lookup(repository) - registered_prototype(repository, author: create_author("aaa-new-owner")) - - second = author_lookup(repository, cursor: first.fetch("nextCursor")) - - assert_equal [ authors.last.id.to_s ], second.fetch("authors").pluck("id") - assert_nil second.fetch("nextCursor") - end - - test "author lookup validates query shapes length and signed cursor scope" do - repository, = sign_in_repository - 21.times do |index| - registered_prototype(repository, author: create_author("needle-#{index.to_s.rjust(2, '0')}")) - end - other_repository = repositories.create(github_id: 2002, name_with_owner: "github/other") - stub_github_permissions([ repository_payload(2002, "github/other") ], { 2002 => "pull" }) - first = author_lookup(repository, q: "needle") - cursor = first.fetch("nextCursor") - assert_kind_of String, cursor - - [ - { q: "x" * 201 }, { q: [ "needle" ] }, { q: { value: "needle" } }, - { cursor: [ cursor ] }, { cursor: "invalid" }, - { cursor: "#{cursor}tampered", q: "needle" }, { cursor:, q: "another" } - ].each do |params| - get authors_path(repository), params:, headers: json_headers - assert_response :bad_request, params.inspect - end - get authors_path(other_repository), params: { cursor:, q: "needle" }, headers: json_headers - assert_response :bad_request - assert_empty author_lookup(repository, q: "x" * 200).fetch("authors") - - sign_in_as_another_reader - get authors_path(repository), params: { cursor:, q: "needle" }, headers: json_headers - assert_response :bad_request - end - test "both listing endpoints require a signed in repository reader and fail closed on permission errors" do visible = repository_payload(2001, "github/prototype") denied = repository_payload(2002, "github/private") @@ -377,50 +289,6 @@ class PrototypeListingTest < ActionDispatch::IntegrationTest end end - test "local cards retain the local identity while author filtering uses saved registration authors" do - root = Rails.root.join("storage", "listing-local-test-#{SecureRandom.hex(6)}") - checkout = root.join("one") - original_enabled = LocalDevelopment.method(:enabled?) - original_root = LocalDevelopment.method(:repositories_root) - LocalDevelopment.define_singleton_method(:enabled?) { true } - LocalDevelopment.define_singleton_method(:repositories_root) { root } - FileUtils.mkdir_p(checkout) - [ - [ "init", "-b", "main" ], - [ "-c", "user.name=Local Test", "-c", "user.email=local@example.invalid", - "commit", "--allow-empty", "-m", "Initial" ] - ].each do |arguments| - _, error, status = Open3.capture3("git", "-C", checkout.to_s, *arguments) - assert status.success?, error - end - host! "replica.localhost:19500" - post local_authentication_path - assert_redirected_to welcome_path - repository = Repository.find_by!(local_path: checkout.to_s) - user = User.find_by!(github_id: nil) - other = create_author("saved-registrant") - mine = registered_prototype(repository, author: user) - theirs = registered_prototype(repository, author: other) - unknown = registered_prototype(repository) - - all = listing(repository) - assert_equal [ mine, theirs, unknown ].map { _1.id.to_s }.sort, - all.fetch("prototypes").pluck("id").sort - all.fetch("prototypes").each do |card| - assert_equal "@#{user.login}", card.fetch("author").fetch("ownerLabel") - assert_equal User::LOCAL_AVATAR_URL, card.fetch("author").fetch("avatarUrl") - assert_nil card.fetch("author").fetch("url") - end - assert_equal [ mine.id.to_s ], listing(repository, author: "me").fetch("prototypes").pluck("id") - assert_equal [ theirs.id.to_s ], - listing(repository, author: other.id.to_s).fetch("prototypes").pluck("id") - assert_equal [ other.id.to_s ], author_lookup(repository).fetch("authors").pluck("id") - ensure - LocalDevelopment.define_singleton_method(:enabled?, original_enabled) if original_enabled - LocalDevelopment.define_singleton_method(:repositories_root, original_root) if original_root - FileUtils.rm_rf(root) if root - end - private def sign_in_repository @@ -442,8 +310,8 @@ def sign_in_as_another_reader sign_in_to_hub(github_id: 1002) end - def create_author(login, name: nil, avatar_url: nil) - users.create(github_id: User.maximum(:github_id).to_i + 1, login:, name:, avatar_url:) + def create_author(login, name: nil) + users.create(github_id: User.maximum(:github_id).to_i + 1, login:, name:, avatar_url: nil) end def registered_prototype(repository, author: nil, name: "Prototype", description: nil, @@ -458,7 +326,6 @@ def listing(repository, **params) end def listing_at(path, params: {}) - assert_kind_of String, path get path, params:, headers: json_headers assert_response :success JSON.parse(response.body).fetch("data") @@ -475,7 +342,6 @@ def author_lookup(repository, **params) end def assert_filters(path, q:, author:) - assert_kind_of String, path params = Rack::Utils.parse_query(URI.parse(path).query) assert_equal q, params.fetch("q") assert_equal author, params.fetch("author") From 36758a83a072c762d1d456c398163e7c22f9157a Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:03:52 +0000 Subject: [PATCH 04/11] refactor(hub): remove unused gallery work after self-review Count only matching results, avoid hydrating an unused prototype, and remove obsolete client-side count fields. Check stale author responses through their visible outcome instead of abort-signal details, retaining the existing test count. Correct the search-clearing documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../prototype-author-filter/index.test.tsx | 30 +++++++++++++++---- .../repositories-show-page/index.test.tsx | 2 -- .../pages/repositories-show-page/stories.tsx | 5 +--- .../pages/repositories-show-page/types.ts | 2 -- .../storybook/fixtures/prototype-listing.ts | 1 - .../utils/prototype-listing/types.ts | 1 - .../models/repository/prototype_listing.rb | 8 ++--- hub/app/views/repositories/show.json.props | 3 -- hub/config/locales/gallery.en.yml | 2 -- hub/docs/gallery.md | 2 +- hub/test/integration/gallery_test.rb | 1 - .../integration/prototype_listing_test.rb | 6 ++-- 12 files changed, 32 insertions(+), 31 deletions(-) diff --git a/hub/app/javascript/features/prototype-author-filter/index.test.tsx b/hub/app/javascript/features/prototype-author-filter/index.test.tsx index 497870fd..ea3cc494 100644 --- a/hub/app/javascript/features/prototype-author-filter/index.test.tsx +++ b/hub/app/javascript/features/prototype-author-filter/index.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor, within } from "@testing-library/react" +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react" import { describe, expect, it, vi } from "vitest" import { prototypeListing } from "@/storybook/fixtures/prototype-listing" @@ -45,7 +45,15 @@ describe("PrototypeAuthorFilter()", () => { expect(trigger).toHaveFocus() }) - it("replaces author option pages and cancels stale searches when the query changes", async () => { + it("replaces author pages and ignores stale results after the search changes", async () => { + let finishOldSearch!: ( + results: Awaited>, + ) => void + const oldSearch = new Promise>>( + (resolve) => { + finishOldSearch = resolve + }, + ) const onSearch = vi .fn(() => Promise.resolve({ @@ -62,18 +70,30 @@ describe("PrototypeAuthorFilter()", () => { ) fireEvent.click(screen.getByRole("button", { name: "Author: All authors" })) expect(await screen.findByRole("menuitemradio", { name: "@octocat" })).toBeInTheDocument() - const firstSignal = onSearch.mock.calls[0]?.[2] fireEvent.click(screen.getByRole("button", { name: "Next authors" })) expect(await screen.findByRole("menuitemradio", { name: "@mona" })).toBeInTheDocument() expect(screen.queryByRole("menuitemradio", { name: "@octocat" })).not.toBeInTheDocument() - expect(firstSignal?.aborted).toBe(true) + onSearch.mockReturnValueOnce(oldSearch) + fireEvent.change(screen.getByRole("searchbox"), { target: { value: "old" } }) + await waitFor(() => + expect(onSearch).toHaveBeenLastCalledWith("old", null, expect.any(AbortSignal)), + ) fireEvent.change(screen.getByRole("searchbox"), { target: { value: "mona" } }) await waitFor(() => expect(onSearch).toHaveBeenLastCalledWith("mona", null, expect.any(AbortSignal)), ) + expect(await screen.findByRole("menuitemradio", { name: "@mona" })).toBeInTheDocument() + await act(async () => { + finishOldSearch({ + authors: [{ id: "3", login: "outdated", name: null, avatarUrl: null }], + nextCursor: null, + }) + await oldSearch + }) + expect(screen.queryByRole("menuitemradio", { name: "@outdated" })).not.toBeInTheDocument() + expect(screen.getByRole("menuitemradio", { name: "@mona" })).toBeInTheDocument() fireEvent.keyDown(screen.getByRole("searchbox"), { key: "Escape" }) expect(screen.queryByRole("menu")).not.toBeInTheDocument() - expect(onSearch.mock.calls.at(-1)?.[2].aborted).toBe(true) }) it("reports lookup failures and retries without losing the pinned Me option", async () => { diff --git a/hub/app/javascript/pages/repositories-show-page/index.test.tsx b/hub/app/javascript/pages/repositories-show-page/index.test.tsx index fd7bdf4c..bb77e2ec 100644 --- a/hub/app/javascript/pages/repositories-show-page/index.test.tsx +++ b/hub/app/javascript/pages/repositories-show-page/index.test.tsx @@ -61,8 +61,6 @@ const PAGE_DATA: RepositoriesShowPageData = { }, ], prototypesCountLabel: "1 prototype", - prototypeCountUnitOne: "prototype", - prototypeCountUnitOther: "prototypes", prototypesHeading: "Prototypes", prototypesLabel: "Prototypes", prototypesPath: "/repositories/2001", diff --git a/hub/app/javascript/pages/repositories-show-page/stories.tsx b/hub/app/javascript/pages/repositories-show-page/stories.tsx index 3343c3df..4772a9c8 100644 --- a/hub/app/javascript/pages/repositories-show-page/stories.tsx +++ b/hub/app/javascript/pages/repositories-show-page/stories.tsx @@ -10,7 +10,7 @@ import { RepositoriesShowPage } from "." import type { RepositoriesShowPageData } from "./types" const data: RepositoriesShowPageData = { - listing: { ...prototypeListing, matchingCount: 0, totalCount: 0 }, + listing: { ...prototypeListing, matchingCount: 0 }, ...authenticatedHeaderData, alert: null, collapseNavigationLabel: "Collapse sidebar", @@ -38,8 +38,6 @@ const data: RepositoriesShowPageData = { pageTitle: "github/prototype", prototypes: [], prototypesCountLabel: "0 prototypes", - prototypeCountUnitOne: "prototype", - prototypeCountUnitOther: "prototypes", prototypesHeading: "Prototypes", prototypesLabel: "Prototypes", prototypesPath: "/repositories/2001", @@ -238,7 +236,6 @@ export const Paginated: Story = { listing: { ...prototypeListing, matchingCount: 50, - totalCount: 50, nextPath: "/repositories/2001?cursor=next", copy: { ...prototypeListing.copy, shown: "2 shown" }, }, diff --git a/hub/app/javascript/pages/repositories-show-page/types.ts b/hub/app/javascript/pages/repositories-show-page/types.ts index c950ed44..55d37b3d 100644 --- a/hub/app/javascript/pages/repositories-show-page/types.ts +++ b/hub/app/javascript/pages/repositories-show-page/types.ts @@ -38,8 +38,6 @@ export interface RepositoriesShowPageData extends RepositoryWorkspaceData { prototypes: PrototypeCardData[] prototypesCountLabel: string prototypesHeading: string - prototypeCountUnitOne: string - prototypeCountUnitOther: string searchLabel: string viewLabel: string workflowInstallation?: WorkflowInstallationData diff --git a/hub/app/javascript/storybook/fixtures/prototype-listing.ts b/hub/app/javascript/storybook/fixtures/prototype-listing.ts index e551c1a9..22daefe3 100644 --- a/hub/app/javascript/storybook/fixtures/prototype-listing.ts +++ b/hub/app/javascript/storybook/fixtures/prototype-listing.ts @@ -5,7 +5,6 @@ export const prototypeListing: PrototypeListing = { author: "", authorLabel: "All authors", matchingCount: 1, - totalCount: 1, previousPath: null, nextPath: null, newestPath: null, diff --git a/hub/app/javascript/utils/prototype-listing/types.ts b/hub/app/javascript/utils/prototype-listing/types.ts index 164a7ab4..a84aed73 100644 --- a/hub/app/javascript/utils/prototype-listing/types.ts +++ b/hub/app/javascript/utils/prototype-listing/types.ts @@ -32,7 +32,6 @@ export interface PrototypeListing { author: string authorLabel: string matchingCount: number - totalCount: number previousPath: string | null nextPath: string | null newestPath: string | null diff --git a/hub/app/models/repository/prototype_listing.rb b/hub/app/models/repository/prototype_listing.rb index 999d0d33..76454eb4 100644 --- a/hub/app/models/repository/prototype_listing.rb +++ b/hub/app/models/repository/prototype_listing.rb @@ -6,7 +6,7 @@ class InvalidQuery < StandardError; end AUTHORS_PAGE_SIZE = 20 QUERY_MAXIMUM_LENGTH = 200 Page = Data.define \ - :prototypes, :query, :author, :author_user, :matching_count, :total_count, + :prototypes, :query, :author, :author_user, :matching_count, :previous_cursor, :next_cursor # Filters before paging, leaving Build hydration to the selected records. @@ -23,8 +23,7 @@ def page(user:, query: nil, author: nil, cursor: nil) "OR users.name LIKE :query ESCAPE '\\'", query: "%#{Prototype.sanitize_sql_like(query)}%" end - total_count = base.count - matching_count = query.empty? && author.empty? ? total_count : relation.count + matching_count = relation.count scope = cursor_scope(user, "prototypes", query:, author:) boundary = read_cursor(cursor, scope) selected = relation @@ -33,7 +32,7 @@ def page(user:, query: nil, author: nil, cursor: nil) end newer = boundary&.fetch("direction") == "previous" rows = selected.order(registered_at: newer ? :asc : :desc, id: newer ? :asc : :desc) - .limit(PAGE_SIZE + 1).to_a.first(PAGE_SIZE) + .limit(PAGE_SIZE).to_a rows.reverse! if newer previous_cursor = nil @@ -51,7 +50,6 @@ def page(user:, query: nil, author: nil, cursor: nil) author:, author_user: author_id && User.where(id: base.select(:author_id)).find_by(id: author_id), matching_count:, - total_count:, previous_cursor:, next_cursor: end diff --git a/hub/app/views/repositories/show.json.props b/hub/app/views/repositories/show.json.props index 879a6877..6be654ed 100644 --- a/hub/app/views/repositories/show.json.props +++ b/hub/app/views/repositories/show.json.props @@ -40,8 +40,6 @@ json.prototypesHeading t("repositories.show.prototypes_heading") json.alert flash[:alert] json.notice flash[:notice] json.prototypesCountLabel t("repositories.show.prototype_count", count: @listing.matching_count) -json.prototypeCountUnitOne t("repositories.show.prototype_count_unit_one") -json.prototypeCountUnitOther t("repositories.show.prototype_count_unit_other") json.searchLabel t("repositories.show.search") json.clearSearchLabel t("repositories.show.clear_search") json.noSearchResultsHeading t("repositories.show.no_search_results_heading") @@ -74,7 +72,6 @@ json.listing do end json.authorLabel author_label json.matchingCount @listing.matching_count - json.totalCount @listing.total_count previous_path = @listing.previous_cursor && repository_path(@repository.route_id, **listing_params, cursor: @listing.previous_cursor) next_path = @listing.next_cursor && diff --git a/hub/config/locales/gallery.en.yml b/hub/config/locales/gallery.en.yml index 52049b9d..2ea89eda 100644 --- a/hub/config/locales/gallery.en.yml +++ b/hub/config/locales/gallery.en.yml @@ -18,8 +18,6 @@ en: prototype_count: one: 1 prototype other: "%{count} prototypes" - prototype_count_unit_one: prototype - prototype_count_unit_other: prototypes search: Search prototypes clear_search: Clear search no_search_results_heading: No prototypes found diff --git a/hub/docs/gallery.md b/hub/docs/gallery.md index 09a21be0..756a3162 100644 --- a/hub/docs/gallery.md +++ b/hub/docs/gallery.md @@ -138,7 +138,7 @@ Local repositories omit workflow installation. Hub checks committed local branch The Prototypes page orders prototypes by when they were first added to Hub, newest first. New prototypes appear at the top left of the gallery and at the top of the list. Editing details or publishing another Build does not change that order. -The Prototypes page filters the current repository's gallery and list as you type in the header search. Search matches prototype names, descriptions, branch names, and owners; Command K (or Control K) focuses the field. The count above the collection reflects matching prototypes, and clearing the search restores the full list. +Command K (or Control K) focuses the Prototypes search field. Clearing search keeps the selected author; **Clear filters** restores the unfiltered collection. Opening a prototype from its card or list row uses in-app navigation rather than reloading the document. List links use normal text colors without underlines. Hovering or focusing an owner's profile link underlines the name; selecting it opens that profile instead of the prototype. The **passing** and **not passing** badges use lowercase text and have no icons. The review shell opens the latest successful Build. Its controls stay above the rounded preview. Gallery cards and Build details show the top of each full-height screenshot in a fixed preview. Hovering a preview scrolls through the remaining image; on touch devices or with reduced motion enabled, scroll the preview manually. In those modes, list screenshots remain manual scroll areas while the rest of each row opens the prototype. Older viewport-height screenshots stay at the top without scrolling. diff --git a/hub/test/integration/gallery_test.rb b/hub/test/integration/gallery_test.rb index 11c1339a..99bc90a5 100644 --- a/hub/test/integration/gallery_test.rb +++ b/hub/test/integration/gallery_test.rb @@ -125,7 +125,6 @@ class GalleryTest < ActionDispatch::IntegrationTest data = JSON.parse(response.body).fetch("data") assert_empty data.fetch("prototypes") assert_equal 0, data.fetch("listing").fetch("matchingCount") - assert_equal 0, data.fetch("listing").fetch("totalCount") %w[previousPath nextPath newestPath].each do |key| assert_nil data.fetch("listing").fetch(key) end diff --git a/hub/test/integration/prototype_listing_test.rb b/hub/test/integration/prototype_listing_test.rb index 92ed2b70..f9e3a5ad 100644 --- a/hub/test/integration/prototype_listing_test.rb +++ b/hub/test/integration/prototype_listing_test.rb @@ -19,7 +19,6 @@ class PrototypeListingTest < ActionDispatch::IntegrationTest first = listing(repository, q: "nEeDlE", author: user.id.to_s) assert_equal expected.first(24), first.fetch("prototypes").pluck("id") assert_equal 49, first.fetch("listing").fetch("matchingCount") - assert_equal 75, first.fetch("listing").fetch("totalCount") assert_nil first.fetch("listing").fetch("previousPath") newest = registered_prototype(repository, author: user, name: "Needle added", at: time + 1.second) @@ -71,12 +70,12 @@ class PrototypeListingTest < ActionDispatch::IntegrationTest cards = data.fetch("prototypes") assert_equal records.reverse.first(24).map { _1.id.to_s }, cards.pluck("id") - assert_equal 60, data.fetch("listing").fetch("totalCount") + assert_equal 60, data.fetch("listing").fetch("matchingCount") cards.each do |card| assert_equal "queued", card.fetch("latestBuild").fetch("state") assert_equal 1, card.fetch("reviewBuild").fetch("number") end - assert_operator instantiated.fetch("Prototype"), :<=, 25 + assert_operator instantiated.fetch("Prototype"), :<=, 24 assert_operator instantiated.fetch("Build"), :<=, 24 * 2 end @@ -154,7 +153,6 @@ class PrototypeListingTest < ActionDispatch::IntegrationTest assert_empty stale.fetch("prototypes") assert_equal 24, stale.fetch("listing").fetch("matchingCount") - assert_equal 24, stale.fetch("listing").fetch("totalCount") newest_path = stale.fetch("listing").fetch("newestPath") assert_filters newest_path, q: "Needle", author: "me" newest = listing_at(newest_path) From 1baa40732f75059793f168cc84551e34194665ba Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:20:52 +0000 Subject: [PATCH 05/11] fix(hub): reuse numbered pagination and truncate author labels Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../controllers/repositories_controller.rb | 2 +- .../prototype-author-filter/index.tsx | 4 +- .../prototype-author-filter/options/index.tsx | 9 +- .../prototype-author-filter/stories.tsx | 25 ++++- .../prototype-author-filter/styles.css | 33 +++++- .../use-prototype-listing/index.test.tsx | 4 +- .../repositories-show-page/index.test.tsx | 10 +- .../pages/repositories-show-page/index.tsx | 88 ++++++--------- .../pages/repositories-show-page/stories.tsx | 2 +- .../pages/repositories-show-page/styles.css | 9 +- .../storybook/fixtures/prototype-listing.ts | 7 +- .../utils/prototype-listing/types.ts | 7 +- .../models/repository/prototype_listing.rb | 61 ++--------- hub/app/views/repositories/show.json.props | 12 +-- hub/config/locales/gallery.en.yml | 1 - hub/docs/gallery.md | 6 +- hub/test/integration/gallery_test.rb | 5 +- .../integration/prototype_listing_test.rb | 101 ++++++++---------- 18 files changed, 185 insertions(+), 201 deletions(-) diff --git a/hub/app/controllers/repositories_controller.rb b/hub/app/controllers/repositories_controller.rb index 531523ec..1d6dbf25 100644 --- a/hub/app/controllers/repositories_controller.rb +++ b/hub/app/controllers/repositories_controller.rb @@ -27,7 +27,7 @@ def show user: Current.user, query: params[:q], author: params[:author], - cursor: params[:cursor] + page: params[:page] @prototypes = @listing.prototypes ActiveRecord::Associations::Preloader.new(records: @prototypes, associations: [ :author, :current_build ]).call prototype_ids = @prototypes.map(&:id) diff --git a/hub/app/javascript/features/prototype-author-filter/index.tsx b/hub/app/javascript/features/prototype-author-filter/index.tsx index 20bae721..064d035e 100644 --- a/hub/app/javascript/features/prototype-author-filter/index.tsx +++ b/hub/app/javascript/features/prototype-author-filter/index.tsx @@ -26,7 +26,9 @@ export function PrototypeAuthorFilter({
    - {label} + + {label} + diff --git a/hub/app/javascript/features/prototype-author-filter/options/index.tsx b/hub/app/javascript/features/prototype-author-filter/options/index.tsx index 51d0c43b..7ddd8f87 100644 --- a/hub/app/javascript/features/prototype-author-filter/options/index.tsx +++ b/hub/app/javascript/features/prototype-author-filter/options/index.tsx @@ -133,8 +133,13 @@ export function PrototypeAuthorOptions({ value={author.id} selected={listing.author === author.id} > - @{author.login} - {author.name ? ` (${author.name})` : ""} + + @{author.login} + {author.name ? ` (${author.name})` : ""} + )) )} diff --git a/hub/app/javascript/features/prototype-author-filter/stories.tsx b/hub/app/javascript/features/prototype-author-filter/stories.tsx index 3a55d14a..9bd51262 100644 --- a/hub/app/javascript/features/prototype-author-filter/stories.tsx +++ b/hub/app/javascript/features/prototype-author-filter/stories.tsx @@ -28,7 +28,14 @@ const meta = { listing={{ ...args.listing, author, - authorLabel: author === "me" ? "Me" : author === "2" ? `@${mona.login}` : "All authors", + authorLabel: + author === args.listing.author + ? args.listing.authorLabel + : author === "me" + ? "Me" + : author === "2" + ? `@${mona.login}` + : "All authors", }} onChange={(value) => { setAuthor(value) @@ -52,6 +59,22 @@ export const Me: Story = { args: { listing: { ...prototypeListing, author: "me", authorLabel: "Me" } }, } +export const LongAuthor: Story = { + args: { + listing: { + ...prototypeListing, + author: "2", + authorLabel: `${mona.name} (@${mona.login})`.repeat(5), + }, + onSearch: fn(() => + Promise.resolve({ + authors: [{ id: "2", ...mona, name: mona.name.repeat(10) }], + nextCursor: null, + }), + ), + }, +} + export const Failed: Story = { args: { onSearch: fn(() => Promise.reject(new Error("Unavailable"))), diff --git a/hub/app/javascript/features/prototype-author-filter/styles.css b/hub/app/javascript/features/prototype-author-filter/styles.css index 1c4dab45..c5ff7036 100644 --- a/hub/app/javascript/features/prototype-author-filter/styles.css +++ b/hub/app/javascript/features/prototype-author-filter/styles.css @@ -10,7 +10,38 @@ } .prototype-author-filter [role="menuitemradio"] { - overflow-wrap: anywhere; + min-width: 0; + grid-template-columns: min-content minmax(0, 1fr); +} + +.prototype-author-filter [aria-haspopup] { + justify-content: space-between; + text-align: start; +} + +.prototype-author-filter [aria-haspopup] > span:first-child, +.prototype-author-filter [role="menuitemradio"] > span:last-child { + min-width: 0; + overflow: hidden; + flex: 1; + width: auto; +} + +.prototype-author-filter [aria-haspopup] > span:first-child > span { + min-width: 0; + width: 100%; + text-align: start; +} + +.prototype-author-filter [role="menuitemradio"] > span:last-child > span { + min-width: 0; +} + +.prototype-author-filter__label { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .prototype-author-filter__search { diff --git a/hub/app/javascript/hooks/use-prototype-listing/index.test.tsx b/hub/app/javascript/hooks/use-prototype-listing/index.test.tsx index 6cffa6e1..026639d9 100644 --- a/hub/app/javascript/hooks/use-prototype-listing/index.test.tsx +++ b/hub/app/javascript/hooks/use-prototype-listing/index.test.tsx @@ -82,7 +82,7 @@ describe("usePrototypeListing()", () => { const { result } = renderHook(() => usePrototypeListing(prototypeListing), { wrapper: navigation(visit), }) - act(() => result.current.onNavigate("/repositories/2001?cursor=old")) + act(() => result.current.onNavigate("/repositories/2001?page=2")) await act(async () => { result.current.onAuthorChange("me") await Promise.resolve() @@ -178,7 +178,7 @@ describe("usePrototypeListing()", () => { initialProps: prototypeListing, wrapper: navigation(visit), }) - act(() => result.current.onNavigate("/repositories/2001?cursor=old")) + act(() => result.current.onNavigate("/repositories/2001?page=2")) act(() => result.current.onQueryChange("unsent old repository search")) expect(result.current.pending).toBe(true) rerender({ diff --git a/hub/app/javascript/pages/repositories-show-page/index.test.tsx b/hub/app/javascript/pages/repositories-show-page/index.test.tsx index bb77e2ec..fa3f149f 100644 --- a/hub/app/javascript/pages/repositories-show-page/index.test.tsx +++ b/hub/app/javascript/pages/repositories-show-page/index.test.tsx @@ -124,19 +124,21 @@ describe("RepositoriesShowPage()", () => { listing: { ...prototypeListing, matchingCount: 80, - nextPath: "/repositories/2001?author=me&cursor=next", + pageCount: 4, + pagePath: "/repositories/2001?q=design&author=me", }, prototypesCountLabel: "80 prototypes", }} />
    , ) - const next = screen.getByRole("link", { name: "Next" }) - expect(next).toHaveAttribute("href", "/repositories/2001?author=me&cursor=next") + const next = screen.getByRole("button", { name: "Page 3" }) + expect(screen.getByRole("button", { name: "Page 1" })).toHaveAttribute("aria-current", "page") + expect(next).toHaveAttribute("href", "/repositories/2001?q=design&author=me&page=3") expect(screen.getByText("80 prototypes (1 shown)")).toBeInTheDocument() fireEvent.click(next) expect(controls.onNavigate).toHaveBeenCalledExactlyOnceWith( - "/repositories/2001?author=me&cursor=next", + "/repositories/2001?q=design&author=me&page=3", ) expect(delegatedClick).not.toHaveBeenCalled() fireEvent.click(next, { ctrlKey: true }) diff --git a/hub/app/javascript/pages/repositories-show-page/index.tsx b/hub/app/javascript/pages/repositories-show-page/index.tsx index d282735b..2e7b4efd 100644 --- a/hub/app/javascript/pages/repositories-show-page/index.tsx +++ b/hub/app/javascript/pages/repositories-show-page/index.tsx @@ -1,8 +1,7 @@ import { useState } from "react" -import type { MouseEvent } from "react" import { GearIcon, GitBranchIcon, SearchIcon } from "@primer/octicons-react" -import { Button, Text } from "@primer/react-brand" +import { Button, Pagination, Text } from "@primer/react-brand" import { HubAlert } from "@components/hub-alert" import { HubEmptyState } from "@components/hub-empty-state" @@ -33,6 +32,11 @@ export function RepositoriesShowPage({ const [view, setView] = useState("gallery") const filtered = Boolean(data.listing.query || data.listing.author) const visiblePrototypes = data.prototypes + function pageHref(page: number) { + const url = new URL(data.listing.pagePath, window.location.origin) + if (page > 1) url.searchParams.set("page", String(page)) + return `${url.pathname}${url.search}` + } const managePrototypesLabelIndex = data.managePrototypesLabel ? data.emptyLabel.indexOf(data.managePrototypesLabel) : -1 @@ -192,19 +196,13 @@ export function RepositoriesShowPage({ /> ) : null} - {(filtered || data.listing.newestPath) && visiblePrototypes.length === 0 ? ( + {filtered && visiblePrototypes.length === 0 ? ( } heading={data.noSearchResultsHeading} description={data.noSearchResultsDescription} - actionLabel={ - data.listing.newestPath ? data.listing.copy.newest : data.listing.copy.clearFilters - } - onAction={() => - data.listing.newestPath - ? controls.onNavigate(data.listing.newestPath) - : controls.onClearFilters() - } + actionLabel={data.listing.copy.clearFilters} + onAction={controls.onClearFilters} /> ) : data.prototypes.length === 0 ? ( data.newPrototypePath ? ( @@ -252,50 +250,34 @@ export function RepositoriesShowPage({ )} - {data.listing.previousPath || data.listing.nextPath || data.listing.newestPath ? ( - + pageCount={data.listing.pageCount} + currentPage={data.listing.page} + hrefBuilder={pageHref} + pageAttributesBuilder={() => ({ "data-sg-visit": "true" })} + labels={{ + prev: data.listing.copy.previous, + prevAriaLabel: data.listing.copy.previous, + next: data.listing.copy.next, + nextAriaLabel: data.listing.copy.next, + }} + onPageChange={(event, page) => { + if ( + event.button === 0 && + !event.metaKey && + !event.ctrlKey && + !event.shiftKey && + !event.altKey + ) { + event.preventDefault() + event.stopPropagation() + controls.onNavigate(pageHref(page)) + } + }} + /> ) : null}
    diff --git a/hub/app/javascript/pages/repositories-show-page/stories.tsx b/hub/app/javascript/pages/repositories-show-page/stories.tsx index 4772a9c8..5164d9ab 100644 --- a/hub/app/javascript/pages/repositories-show-page/stories.tsx +++ b/hub/app/javascript/pages/repositories-show-page/stories.tsx @@ -236,7 +236,7 @@ export const Paginated: Story = { listing: { ...prototypeListing, matchingCount: 50, - nextPath: "/repositories/2001?cursor=next", + pageCount: 3, copy: { ...prototypeListing.copy, shown: "2 shown" }, }, prototypesCountLabel: "50 prototypes", diff --git a/hub/app/javascript/pages/repositories-show-page/styles.css b/hub/app/javascript/pages/repositories-show-page/styles.css index 86c4151c..31d4339e 100644 --- a/hub/app/javascript/pages/repositories-show-page/styles.css +++ b/hub/app/javascript/pages/repositories-show-page/styles.css @@ -13,14 +13,7 @@ } .repository-detail__pagination { - display: flex; - justify-content: flex-end; - flex-wrap: wrap; - gap: var(--hub-space-16); -} - -.repository-detail__previous-page { - margin-inline-end: auto; + align-self: center; } .repository-detail__count { diff --git a/hub/app/javascript/storybook/fixtures/prototype-listing.ts b/hub/app/javascript/storybook/fixtures/prototype-listing.ts index 22daefe3..ff0b5b0b 100644 --- a/hub/app/javascript/storybook/fixtures/prototype-listing.ts +++ b/hub/app/javascript/storybook/fixtures/prototype-listing.ts @@ -5,9 +5,9 @@ export const prototypeListing: PrototypeListing = { author: "", authorLabel: "All authors", matchingCount: 1, - previousPath: null, - nextPath: null, - newestPath: null, + page: 1, + pageCount: 1, + pagePath: "/repositories/2001", authorsPath: "/repositories/2001/prototype_authors", path: "/repositories/2001", copy: { @@ -25,7 +25,6 @@ export const prototypeListing: PrototypeListing = { clearFilters: "Clear filters", previous: "Previous", next: "Next", - newest: "Newest results", pagination: "Prototype pages", updating: "Updating prototypes", shown: "1 shown", diff --git a/hub/app/javascript/utils/prototype-listing/types.ts b/hub/app/javascript/utils/prototype-listing/types.ts index a84aed73..9c85bf6f 100644 --- a/hub/app/javascript/utils/prototype-listing/types.ts +++ b/hub/app/javascript/utils/prototype-listing/types.ts @@ -32,9 +32,9 @@ export interface PrototypeListing { author: string authorLabel: string matchingCount: number - previousPath: string | null - nextPath: string | null - newestPath: string | null + page: number + pageCount: number + pagePath: string authorsPath: string path: string copy: { @@ -52,7 +52,6 @@ export interface PrototypeListing { clearFilters: string previous: string next: string - newest: string pagination: string updating: string shown: string diff --git a/hub/app/models/repository/prototype_listing.rb b/hub/app/models/repository/prototype_listing.rb index 76454eb4..300444a8 100644 --- a/hub/app/models/repository/prototype_listing.rb +++ b/hub/app/models/repository/prototype_listing.rb @@ -7,10 +7,10 @@ class InvalidQuery < StandardError; end QUERY_MAXIMUM_LENGTH = 200 Page = Data.define \ :prototypes, :query, :author, :author_user, :matching_count, - :previous_cursor, :next_cursor + :page, :page_count # Filters before paging, leaving Build hydration to the selected records. - def page(user:, query: nil, author: nil, cursor: nil) + def page(user:, query: nil, author: nil, page: nil) query = normalized_query(query) author = normalized_author(author) author_id = author == "me" ? user.id : author.presence&.to_i @@ -24,25 +24,12 @@ def page(user:, query: nil, author: nil, cursor: nil) query: "%#{Prototype.sanitize_sql_like(query)}%" end matching_count = relation.count - scope = cursor_scope(user, "prototypes", query:, author:) - boundary = read_cursor(cursor, scope) - selected = relation - if boundary - selected = before_boundary(selected, boundary, newer: boundary.fetch("direction") == "previous") - end - newer = boundary&.fetch("direction") == "previous" - rows = selected.order(registered_at: newer ? :asc : :desc, id: newer ? :asc : :desc) - .limit(PAGE_SIZE).to_a - rows.reverse! if newer - - previous_cursor = nil - next_cursor = nil - if rows.any? - first = prototype_boundary(rows.first, "previous") - last = prototype_boundary(rows.last, "next") - previous_cursor = write_cursor(scope, first) if before_boundary(relation, first, newer: true).exists? - next_cursor = write_cursor(scope, last) if before_boundary(relation, last, newer: false).exists? - end + raise InvalidQuery if page && (!page.is_a?(String) || !page.match?(/\A[1-9]\d{0,8}\z/)) + + page_count = [ (matching_count.to_f / PAGE_SIZE).ceil, 1 ].max + page = [ page ? page.to_i : 1, page_count ].min + rows = relation.order(registered_at: :desc, id: :desc) + .offset((page - 1) * PAGE_SIZE).limit(PAGE_SIZE).to_a Page.new \ prototypes: rows, @@ -50,8 +37,8 @@ def page(user:, query: nil, author: nil, cursor: nil) author:, author_user: author_id && User.where(id: base.select(:author_id)).find_by(id: author_id), matching_count:, - previous_cursor:, - next_cursor: + page:, + page_count: end # Searches all eligible registration authors without enumerating the gallery. @@ -63,7 +50,7 @@ def authors(user:, query: nil, cursor: nil) "login LIKE :query ESCAPE '\\' OR name LIKE :query ESCAPE '\\'", query: "%#{User.sanitize_sql_like(query)}%" end - scope = cursor_scope(user, "authors", query:) + scope = { "version" => 1, "repository" => record.id, "user" => user.id, "query" => query } boundary = read_cursor(cursor, scope) if boundary raise InvalidQuery unless boundary["login"].is_a?(String) && boundary["login"].length <= 255 @@ -104,11 +91,6 @@ def normalized_author(value) value end - # Signs the query and viewer as well as the boundary to reject mixed continuations. - def cursor_scope(user, kind, query:, author: "") - { "version" => 1, "repository" => record.id, "user" => user.id, "kind" => kind, "query" => query, "author" => author } - end - # Validates only server-issued boundaries, never treating them as authorization. def read_cursor(value, scope) return if value.nil? @@ -123,27 +105,6 @@ def read_cursor(value, scope) payload end - # Keeps full timestamp precision for prototypes registered at the same time. - def prototype_boundary(prototype, direction) - { "time" => prototype.registered_at.iso8601(6), "id" => prototype.id, "direction" => direction } - end - - # Uses strict tuple boundaries so new registrations do not shift later pages. - def before_boundary(relation, boundary, newer:) - unless boundary["time"].is_a?(String) && %w[previous next].include?(boundary["direction"]) - raise InvalidQuery - end - - time = Time.iso8601(boundary.fetch("time")) - operator = newer ? ">" : "<" - relation.where \ - "prototypes.registered_at #{operator} :time " \ - "OR (prototypes.registered_at = :time AND prototypes.id #{operator} :id)", - time:, id: boundary.fetch("id") - rescue ArgumentError - raise InvalidQuery - end - # Adds the boundary to the exact source query. def write_cursor(scope, boundary) verifier.generate(scope.merge(boundary)) diff --git a/hub/app/views/repositories/show.json.props b/hub/app/views/repositories/show.json.props index 6be654ed..767212f5 100644 --- a/hub/app/views/repositories/show.json.props +++ b/hub/app/views/repositories/show.json.props @@ -72,18 +72,14 @@ json.listing do end json.authorLabel author_label json.matchingCount @listing.matching_count - previous_path = @listing.previous_cursor && - repository_path(@repository.route_id, **listing_params, cursor: @listing.previous_cursor) - next_path = @listing.next_cursor && - repository_path(@repository.route_id, **listing_params, cursor: @listing.next_cursor) - json.previousPath previous_path - json.nextPath next_path - json.newestPath(params[:cursor].present? ? repository_path(@repository.route_id, **listing_params) : nil) + json.page @listing.page + json.pageCount @listing.page_count + json.pagePath repository_path(@repository.route_id, **listing_params) json.authorsPath repository_prototype_authors_path(@repository.route_id) json.path repository_path(@repository.route_id) json.copy do %w[author me all_authors search_authors more_authors first_authors no_authors loading retry - authors_failed results_failed clear_filters previous next newest pagination updating shown].each do |key| + authors_failed results_failed clear_filters previous next pagination updating shown].each do |key| json.set! key.camelize(:lower), t("repositories.show.filters.#{key}", count: @prototypes.length) end end diff --git a/hub/config/locales/gallery.en.yml b/hub/config/locales/gallery.en.yml index 2ea89eda..ffa8c6b4 100644 --- a/hub/config/locales/gallery.en.yml +++ b/hub/config/locales/gallery.en.yml @@ -62,7 +62,6 @@ en: clear_filters: Clear filters previous: Previous next: Next - newest: Newest results pagination: Prototype pages updating: Updating prototypes shown: "%{count} shown" diff --git a/hub/docs/gallery.md b/hub/docs/gallery.md index 756a3162..5dd9ccbc 100644 --- a/hub/docs/gallery.md +++ b/hub/docs/gallery.md @@ -60,15 +60,15 @@ Operators configure the video through the [deployment settings](deployment.md#we ## Browse prototypes -Prototypes appear in pages of up to 24, newest registration first. **Previous**, **Next**, and **Newest results** keep the current search and author filter. Hub loads card and Build data only for the selected page. A new registration appears on the newest page without shifting the boundary used to continue browsing older results. +Prototypes appear in pages of up to 24, newest registration first. Numbered navigation with **Previous** and **Next** uses the same Primer Brand pagination component as Manage prototypes and keeps the current search and author filter. Hub loads card and Build data only for the selected page. New registrations or removals can shift records between pages while browsing. Search matches prototype names, descriptions, branches, and registration authors across the full selected repository, not only the visible page. Search treats punctuation such as `%` and `_` literally. SQLite's built-in matching ignores ASCII letter case; non-ASCII letters retain SQLite's case behavior. The count shows all matches, with the number shown on the current page when fewer are displayed. -The **Author** dropdown puts **Me** first, followed by **All authors** and searchable pages of the repository's registration authors. **Me** uses the signed-in person's saved identity. Cards and the list's **Author** column show the first person who added the prototype to Hub; later commits by another person do not change attribution or author-filter results. Prototypes without a saved author remain visible with no author filter and display **Author unavailable**. Local mode keeps its existing local-identity presentation and applies author filters to saved registration authors. +The **Author** dropdown puts **Me** first, followed by **All authors** and searchable pages of the repository's registration authors. Long labels and options truncate with an ellipsis rather than widening the dropdown or scrolling horizontally; hovering reveals their full text. **Me** uses the signed-in person's saved identity. Cards and the list's **Author** column show the first person who added the prototype to Hub; later commits by another person do not change attribution or author-filter results. Prototypes without a saved author remain visible with no author filter and display **Author unavailable**. Local mode keeps its existing local-identity presentation and applies author filters to saved registration authors. Search and author filters combine. Applying either starts again at the newest matching page; **Clear filters** removes both. The URL preserves applied search, author, and page state across reloads and browser history. Switching repositories starts without those filters. Author choices load in bounded pages and are not restricted to authors on the visible prototype page. Clearing or changing author-option search starts its choices from the beginning. -While Hub fetches results, it keeps the previous cards visible and announces the update. A failed request keeps those cards with an explicit error and **Retry**. No matches has its own empty state, separate from a repository with no prototypes. If a previously valid page has become empty, **Newest results** returns to the current matching collection. +While Hub fetches results, it keeps the previous cards visible and announces the update. A failed request keeps those cards with an explicit error and **Retry**. No matches has its own empty state, separate from a repository with no prototypes. A page beyond the remaining results shows the last available page. An empty Prototypes page shows a branch Octicon, a heading, and guidance. For GitHub repositories, **Build prototype** opens **Create**. Local repositories keep their branch-discovery guidance without offering the Copilot app flow. diff --git a/hub/test/integration/gallery_test.rb b/hub/test/integration/gallery_test.rb index 99bc90a5..9f9109e0 100644 --- a/hub/test/integration/gallery_test.rb +++ b/hub/test/integration/gallery_test.rb @@ -125,8 +125,11 @@ class GalleryTest < ActionDispatch::IntegrationTest data = JSON.parse(response.body).fetch("data") assert_empty data.fetch("prototypes") assert_equal 0, data.fetch("listing").fetch("matchingCount") + assert_equal 1, data.fetch("listing").fetch("page") + assert_equal 1, data.fetch("listing").fetch("pageCount") + assert_equal repository_path(repository.route_id), data.fetch("listing").fetch("pagePath") %w[previousPath nextPath newestPath].each do |key| - assert_nil data.fetch("listing").fetch(key) + assert_not data.fetch("listing").key?(key) end assert_equal "No prototypes yet", data.fetch("emptyHeading") assert_equal "Build prototype", data.fetch("buildPrototypeLabel") diff --git a/hub/test/integration/prototype_listing_test.rb b/hub/test/integration/prototype_listing_test.rb index f9e3a5ad..775625ba 100644 --- a/hub/test/integration/prototype_listing_test.rb +++ b/hub/test/integration/prototype_listing_test.rb @@ -5,7 +5,7 @@ class PrototypeListingTest < ActionDispatch::IntegrationTest github_app_configurations.create_installed end - test "filters before paging and preserves stable continuations when a prototype is registered" do + test "filters before numbered paging and preserves filters in the page path" do repository, user = sign_in_repository other = create_author("other-reviewer") time = Time.current @@ -16,27 +16,22 @@ class PrototypeListingTest < ActionDispatch::IntegrationTest registered_prototype(repository, author: user, name: "Unrelated", at: time) expected = matching.reverse.map { _1.id.to_s } - first = listing(repository, q: "nEeDlE", author: user.id.to_s) - assert_equal expected.first(24), first.fetch("prototypes").pluck("id") - assert_equal 49, first.fetch("listing").fetch("matchingCount") - assert_nil first.fetch("listing").fetch("previousPath") - newest = registered_prototype(repository, author: user, name: "Needle added", at: time + 1.second) - - next_path = first.fetch("listing").fetch("nextPath") - assert_filters next_path, q: "nEeDlE", author: user.id.to_s - second = listing_at(next_path) - assert_equal expected.slice(24, 24), second.fetch("prototypes").pluck("id") - assert_equal 50, second.fetch("listing").fetch("matchingCount") - assert_filters second.fetch("listing").fetch("newestPath"), q: "nEeDlE", author: user.id.to_s - previous_path = second.fetch("listing").fetch("previousPath") - assert_filters previous_path, q: "nEeDlE", author: user.id.to_s - previous = listing_at(previous_path) - assert_equal expected.first(24), previous.fetch("prototypes").pluck("id") - third = listing_at(second.fetch("listing").fetch("nextPath")) - assert_equal expected.last(1), third.fetch("prototypes").pluck("id") - assert_nil third.fetch("listing").fetch("nextPath") - assert_equal [ newest.id.to_s, *expected ].first(24), - listing_at(third.fetch("listing").fetch("newestPath")).fetch("prototypes").pluck("id") + data = listing(repository, q: "nEeDlE", author: user.id.to_s, page: 3) + [ 3, 2, 1 ].each do |page| + assert_equal expected.slice((page - 1) * 24, 24), data.fetch("prototypes").pluck("id") + pagination = data.fetch("listing") + assert_equal page, pagination.fetch("page") + assert_equal 3, pagination.fetch("pageCount") + assert_equal 49, pagination.fetch("matchingCount") + assert_equal repository_path(repository.route_id), pagination.fetch("path") + assert_equal authors_path(repository), pagination.fetch("authorsPath") + page_path = pagination.fetch("pagePath") + assert_equal repository_path(repository.route_id), URI.parse(page_path).path + assert_filters page_path, q: "nEeDlE", author: user.id.to_s + assert_equal %w[author q], Rack::Utils.parse_query(URI.parse(page_path).query).keys.sort + %w[previousPath nextPath newestPath].each { assert_not pagination.key?(_1) } + data = listing_at(page_path, params: { page: page - 1 }) if page > 1 + end end test "selects only eligible prototypes and bounds card and Build hydration to 24 results" do @@ -70,6 +65,7 @@ class PrototypeListingTest < ActionDispatch::IntegrationTest cards = data.fetch("prototypes") assert_equal records.reverse.first(24).map { _1.id.to_s }, cards.pluck("id") + assert_equal 1, data.fetch("listing").fetch("page") assert_equal 60, data.fetch("listing").fetch("matchingCount") cards.each do |card| assert_equal "queued", card.fetch("latestBuild").fetch("state") @@ -143,30 +139,31 @@ class PrototypeListingTest < ActionDispatch::IntegrationTest assert_includes named.fetch("listing").fetch("authorLabel"), other.login end - test "a stale valid cursor returns an empty filtered page with a route back to newest" do + test "clamps a stale page to the last filtered page after a deletion" do repository, user = sign_in_repository originals = 25.times.map { registered_prototype(repository, author: user, name: "Needle") } - first = listing(repository, q: "Needle", author: "me") + second = listing(repository, q: "Needle", author: "me", page: 2) + assert_equal [ originals.first.id.to_s ], second.fetch("prototypes").pluck("id") + assert_equal 2, second.fetch("listing").fetch("pageCount") originals.first.update!(branch_deleted_at: Time.current) - stale = listing_at(first.fetch("listing").fetch("nextPath")) + stale = listing_at(second.fetch("listing").fetch("pagePath"), params: { page: 2 }) - assert_empty stale.fetch("prototypes") + assert_equal originals.drop(1).reverse.map { _1.id.to_s }, stale.fetch("prototypes").pluck("id") + assert_equal 1, stale.fetch("listing").fetch("page") + assert_equal 1, stale.fetch("listing").fetch("pageCount") assert_equal 24, stale.fetch("listing").fetch("matchingCount") - newest_path = stale.fetch("listing").fetch("newestPath") - assert_filters newest_path, q: "Needle", author: "me" - newest = listing_at(newest_path) - assert_equal 24, newest.fetch("prototypes").size - assert_nil newest.fetch("listing").fetch("nextPath") + assert_filters stale.fetch("listing").fetch("pagePath"), q: "Needle", author: "me" end - test "rejects malformed filters and cursors instead of silently returning an unfiltered collection" do + test "rejects malformed filters and page numbers instead of silently defaulting them" do repository, user = sign_in_repository registered_prototype(repository, author: user) [ { q: "x" * 201 }, { q: [ "query" ] }, { author: "0" }, - { cursor: "not-a-signed-cursor" }, { cursor: [ "cursor" ] } + *[ "", "0", "-1", "1.5", "+1", "1e1", " 1", "1 ", "1", "1000000000", + [ "1" ], { value: "1" } ].map { { page: _1 } } ].each do |params| get repository_path(repository.github_id), params:, headers: json_headers assert_response :bad_request, params.inspect @@ -174,41 +171,33 @@ class PrototypeListingTest < ActionDispatch::IntegrationTest accepted = listing(repository, q: "x" * 200) assert_equal "x" * 200, accepted.fetch("listing").fetch("query") + assert_equal 1, accepted.fetch("listing").fetch("page") + last = listing(repository, page: "999999999") + assert_equal 1, last.fetch("listing").fetch("page") + assert_equal 1, last.fetch("listing").fetch("pageCount") + assert_equal 1, last.fetch("listing").fetch("matchingCount") end - test "both cursor types reject tampering and changes to repository filter or viewer" do - repository, user = sign_in_repository + test "author cursors reject tampering and changes to repository query or viewer" do + repository, = sign_in_repository 25.times do |index| registered_prototype(repository, author: create_author("needle-#{index}"), name: "Needle") end - first = listing(repository, q: "Needle") - path = first.fetch("listing").fetch("nextPath") - params = Rack::Utils.parse_query(URI.parse(path).query) + query = { "q" => "needle", "cursor" => author_lookup(repository, q: "needle").fetch("nextCursor") } + assert_not_nil query.fetch("cursor") other_repository = repositories.create(github_id: 2002, name_with_owner: "github/other") stub_github_permissions([ repository_payload(2002, "github/other") ], { 2002 => "pull" }) - endpoints = [ - [ repository_path(repository.github_id), repository_path(other_repository.github_id), params ], - [ authors_path(repository), authors_path(other_repository), - { "q" => "needle", "cursor" => author_lookup(repository, q: "needle").fetch("nextCursor") } ] - ] - - endpoints.each do |endpoint, foreign_endpoint, query| - [ query.merge("cursor" => "#{query.fetch('cursor')}tampered"), - query.merge("q" => "Different") ].each do |invalid| - get endpoint, params: invalid, headers: json_headers - assert_response :bad_request - end - get foreign_endpoint, params: query, headers: json_headers + [ query.merge("cursor" => "#{query.fetch('cursor')}tampered"), + query.merge("q" => "Different") ].each do |invalid| + get authors_path(repository), params: invalid, headers: json_headers assert_response :bad_request end - get repository_path(repository.github_id), params: params.merge("author" => user.id.to_s), headers: json_headers + get authors_path(other_repository), params: query, headers: json_headers assert_response :bad_request sign_in_as_another_reader - endpoints.each do |endpoint, _, query| - get endpoint, params: query, headers: json_headers - assert_response :bad_request - end + get authors_path(repository), params: query, headers: json_headers + assert_response :bad_request end test "pages distinct registrants outside the loaded page without shifting for new authors" do From 398ef1840dda377ff3463c8d3384b988ee4d27d5 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:38:08 +0000 Subject: [PATCH 06/11] fix(hub): use available author option width and center More Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../features/prototype-author-filter/index.test.tsx | 2 +- .../javascript/features/prototype-author-filter/stories.tsx | 4 ++-- .../javascript/features/prototype-author-filter/styles.css | 2 ++ hub/app/javascript/storybook/fixtures/prototype-listing.ts | 2 +- hub/config/locales/gallery.en.yml | 2 +- hub/docs/gallery.md | 2 +- 6 files changed, 8 insertions(+), 6 deletions(-) diff --git a/hub/app/javascript/features/prototype-author-filter/index.test.tsx b/hub/app/javascript/features/prototype-author-filter/index.test.tsx index ea3cc494..e2baf4bc 100644 --- a/hub/app/javascript/features/prototype-author-filter/index.test.tsx +++ b/hub/app/javascript/features/prototype-author-filter/index.test.tsx @@ -70,7 +70,7 @@ describe("PrototypeAuthorFilter()", () => { ) fireEvent.click(screen.getByRole("button", { name: "Author: All authors" })) expect(await screen.findByRole("menuitemradio", { name: "@octocat" })).toBeInTheDocument() - fireEvent.click(screen.getByRole("button", { name: "Next authors" })) + fireEvent.click(screen.getByRole("button", { name: "More" })) expect(await screen.findByRole("menuitemradio", { name: "@mona" })).toBeInTheDocument() expect(screen.queryByRole("menuitemradio", { name: "@octocat" })).not.toBeInTheDocument() onSearch.mockReturnValueOnce(oldSearch) diff --git a/hub/app/javascript/features/prototype-author-filter/stories.tsx b/hub/app/javascript/features/prototype-author-filter/stories.tsx index 9bd51262..f592be90 100644 --- a/hub/app/javascript/features/prototype-author-filter/stories.tsx +++ b/hub/app/javascript/features/prototype-author-filter/stories.tsx @@ -66,10 +66,10 @@ export const LongAuthor: Story = { author: "2", authorLabel: `${mona.name} (@${mona.login})`.repeat(5), }, - onSearch: fn(() => + onSearch: fn((_query: string, cursor: string | null) => Promise.resolve({ authors: [{ id: "2", ...mona, name: mona.name.repeat(10) }], - nextCursor: null, + nextCursor: cursor ? null : "next", }), ), }, diff --git a/hub/app/javascript/features/prototype-author-filter/styles.css b/hub/app/javascript/features/prototype-author-filter/styles.css index c5ff7036..f175bca2 100644 --- a/hub/app/javascript/features/prototype-author-filter/styles.css +++ b/hub/app/javascript/features/prototype-author-filter/styles.css @@ -12,6 +12,7 @@ .prototype-author-filter [role="menuitemradio"] { min-width: 0; grid-template-columns: min-content minmax(0, 1fr); + padding-inline-end: var(--brand-control-small-paddingInline-condensed); } .prototype-author-filter [aria-haspopup] { @@ -60,6 +61,7 @@ .prototype-author-filter__paging { display: flex; + justify-content: center; flex-wrap: wrap; gap: var(--base-size-8); margin-top: var(--base-size-8); diff --git a/hub/app/javascript/storybook/fixtures/prototype-listing.ts b/hub/app/javascript/storybook/fixtures/prototype-listing.ts index ff0b5b0b..08cbb66a 100644 --- a/hub/app/javascript/storybook/fixtures/prototype-listing.ts +++ b/hub/app/javascript/storybook/fixtures/prototype-listing.ts @@ -15,7 +15,7 @@ export const prototypeListing: PrototypeListing = { me: "Me", allAuthors: "All authors", searchAuthors: "Search authors", - moreAuthors: "Next authors", + moreAuthors: "More", firstAuthors: "First authors", noAuthors: "No matching authors", loading: "Loading authors", diff --git a/hub/config/locales/gallery.en.yml b/hub/config/locales/gallery.en.yml index ffa8c6b4..5d87b84c 100644 --- a/hub/config/locales/gallery.en.yml +++ b/hub/config/locales/gallery.en.yml @@ -52,7 +52,7 @@ en: me: Me all_authors: All authors search_authors: Search authors - more_authors: Next authors + more_authors: More first_authors: First authors no_authors: No matching authors loading: Loading authors diff --git a/hub/docs/gallery.md b/hub/docs/gallery.md index 5dd9ccbc..0516adfa 100644 --- a/hub/docs/gallery.md +++ b/hub/docs/gallery.md @@ -66,7 +66,7 @@ Search matches prototype names, descriptions, branches, and registration authors The **Author** dropdown puts **Me** first, followed by **All authors** and searchable pages of the repository's registration authors. Long labels and options truncate with an ellipsis rather than widening the dropdown or scrolling horizontally; hovering reveals their full text. **Me** uses the signed-in person's saved identity. Cards and the list's **Author** column show the first person who added the prototype to Hub; later commits by another person do not change attribution or author-filter results. Prototypes without a saved author remain visible with no author filter and display **Author unavailable**. Local mode keeps its existing local-identity presentation and applies author filters to saved registration authors. -Search and author filters combine. Applying either starts again at the newest matching page; **Clear filters** removes both. The URL preserves applied search, author, and page state across reloads and browser history. Switching repositories starts without those filters. Author choices load in bounded pages and are not restricted to authors on the visible prototype page. Clearing or changing author-option search starts its choices from the beginning. +Search and author filters combine. Applying either starts again at the newest matching page; **Clear filters** removes both. The URL preserves applied search, author, and page state across reloads and browser history. Switching repositories starts without those filters. Author choices load in bounded pages and are not restricted to authors on the visible prototype page; **More** loads the next page of authors. Clearing or changing author-option search starts its choices from the beginning. While Hub fetches results, it keeps the previous cards visible and announces the update. A failed request keeps those cards with an explicit error and **Retry**. No matches has its own empty state, separate from a repository with no prototypes. A page beyond the remaining results shows the last available page. From 40342fbcce641a942f4aa29b6ec2c3ee46cec0cd Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:03:18 +0000 Subject: [PATCH 07/11] test(hub): advance lock retries to their scheduled due time Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- hub/test/integration/learning_execution_test.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hub/test/integration/learning_execution_test.rb b/hub/test/integration/learning_execution_test.rb index 9d69c362..ef58a1dc 100644 --- a/hub/test/integration/learning_execution_test.rb +++ b/hub/test/integration/learning_execution_test.rb @@ -165,7 +165,7 @@ def queue_adapter_for_test claim(Learning::Run::FindLearningsJob.name).perform assert_equal "queued", run.reload.state assert_nil run.started_at - travel 35.seconds + travel_to SolidQueue::ScheduledExecution.sole.scheduled_at + 1.second assert_equal 1, SolidQueue::ScheduledExecution.dispatch_next_batch(100) end assert_raises(Learning::ExecutionLock::Busy) { claim(Learning::Run::FindLearningsJob.name).perform } @@ -201,7 +201,7 @@ def queue_adapter_for_test (attempts - 1).times do claim(ReviewComment::RemoveFromLearningStoreJob.name).perform assert @comment.reload.learning_removal_due_at - travel 35.seconds + travel_to SolidQueue::ScheduledExecution.sole.scheduled_at + 1.second assert_equal 1, SolidQueue::ScheduledExecution.dispatch_next_batch(100) end assert_raises(Learning::ExecutionLock::Busy) { claim(ReviewComment::RemoveFromLearningStoreJob.name).perform } From 65b0982f6b00941f067cf1ead7a7ebf5ddeb4fcb Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:50:59 +0000 Subject: [PATCH 08/11] fix(hub): simplify filter controls and clarify enabled states Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../features/prototype-author-filter/index.test.tsx | 3 +++ .../prototype-author-filter/options/index.tsx | 2 +- .../features/prototype-author-filter/styles.css | 5 +++++ .../features/prototype-index-header/index.tsx | 12 +++++++----- .../features/prototype-index-header/styles.css | 13 ++++++++----- .../hooks/use-prototype-listing/index.test.tsx | 6 ++++-- .../javascript/hooks/use-prototype-listing/index.ts | 7 ------- .../pages/repositories-show-page/index.test.tsx | 1 - .../pages/repositories-show-page/index.tsx | 9 --------- .../pages/repositories-show-page/stories.tsx | 1 - .../storybook/fixtures/prototype-listing.ts | 1 - hub/app/javascript/utils/prototype-listing/types.ts | 2 -- hub/app/views/repositories/show.json.props | 2 +- hub/config/locales/gallery.en.yml | 1 - hub/docs/gallery.md | 4 ++-- 15 files changed, 31 insertions(+), 38 deletions(-) diff --git a/hub/app/javascript/features/prototype-author-filter/index.test.tsx b/hub/app/javascript/features/prototype-author-filter/index.test.tsx index e2baf4bc..6ab935fa 100644 --- a/hub/app/javascript/features/prototype-author-filter/index.test.tsx +++ b/hub/app/javascript/features/prototype-author-filter/index.test.tsx @@ -108,6 +108,9 @@ describe("PrototypeAuthorFilter()", () => { expect(screen.getByRole("menuitemradio", { name: "Me" })).toBeInTheDocument() fireEvent.click(screen.getByRole("button", { name: "Retry" })) expect(await screen.findByRole("status")).toHaveTextContent(prototypeListing.copy.loading) + await waitFor(() => expect(screen.queryByRole("status")).not.toBeInTheDocument()) + expect(screen.getByRole("menuitemradio", { name: "Me" })).toBeInTheDocument() + fireEvent.change(screen.getByRole("searchbox"), { target: { value: "missing" } }) await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("No matching authors")) }) }) diff --git a/hub/app/javascript/features/prototype-author-filter/options/index.tsx b/hub/app/javascript/features/prototype-author-filter/options/index.tsx index 7ddd8f87..abecb264 100644 --- a/hub/app/javascript/features/prototype-author-filter/options/index.tsx +++ b/hub/app/javascript/features/prototype-author-filter/options/index.tsx @@ -121,7 +121,7 @@ export function PrototypeAuthorOptions({ - ) : !results || !results.authors.length ? ( + ) : !results || (!results.authors.length && query.trim()) ? (
  • {results ? copy.noAuthors : copy.loading}

  • diff --git a/hub/app/javascript/features/prototype-author-filter/styles.css b/hub/app/javascript/features/prototype-author-filter/styles.css index f175bca2..07dfeeba 100644 --- a/hub/app/javascript/features/prototype-author-filter/styles.css +++ b/hub/app/javascript/features/prototype-author-filter/styles.css @@ -18,6 +18,11 @@ .prototype-author-filter [aria-haspopup] { justify-content: space-between; text-align: start; + color: var(--hub-fg-default); +} + +.prototype-author-filter [aria-haspopup]:not(:hover):not(:active):not([aria-expanded="true"]) { + background-color: var(--hub-canvas-default); } .prototype-author-filter [aria-haspopup] > span:first-child, diff --git a/hub/app/javascript/features/prototype-index-header/index.tsx b/hub/app/javascript/features/prototype-index-header/index.tsx index bacb3bbd..b50962ae 100644 --- a/hub/app/javascript/features/prototype-index-header/index.tsx +++ b/hub/app/javascript/features/prototype-index-header/index.tsx @@ -4,6 +4,8 @@ import type { ReactNode } from "react" import { XIcon } from "@primer/octicons-react" import { Heading, Text } from "@primer/react-brand" +import { HubIconButton } from "@components/hub-icon-button" + import "./styles.css" /** Heads the prototype collection and filters its contents. */ @@ -80,17 +82,17 @@ export function PrototypeIndexHeader({ }} /> {searchQuery ? ( - + /> ) : null} + + {trailingTools ? ( +
    {trailingTools}
    + ) : null} ) diff --git a/hub/app/javascript/features/prototype-index-header/styles.css b/hub/app/javascript/features/prototype-index-header/styles.css index 83051fd6..2220d775 100644 --- a/hub/app/javascript/features/prototype-index-header/styles.css +++ b/hub/app/javascript/features/prototype-index-header/styles.css @@ -34,6 +34,12 @@ height: var(--base-size-36); } +.prototype-index-header__trailing-tools { + display: flex; + align-items: center; + margin-inline-start: var(--base-size-8); +} + .prototype-index-header__search input { width: 100%; height: 100%; @@ -135,4 +141,9 @@ .prototype-index-header__search { width: 100%; } + + .prototype-index-header__trailing-tools { + align-self: flex-end; + margin-inline-start: 0; + } } diff --git a/hub/app/javascript/pages/repositories-show-page/index.tsx b/hub/app/javascript/pages/repositories-show-page/index.tsx index a168c3c5..d857f8df 100644 --- a/hub/app/javascript/pages/repositories-show-page/index.tsx +++ b/hub/app/javascript/pages/repositories-show-page/index.tsx @@ -100,16 +100,17 @@ export function RepositoriesShowPage({ onChange={controls.onAuthorChange} onSearch={controls.loadAuthors} /> - - } + trailingTools={ + + } />
    Date: Thu, 22 Oct 2026 15:02:17 +0000 Subject: [PATCH 10/11] fix(hub): soften author lookup loading state Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../prototype-author-filter/options/index.tsx | 12 ++++++++++-- .../features/prototype-author-filter/styles.css | 8 ++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/hub/app/javascript/features/prototype-author-filter/options/index.tsx b/hub/app/javascript/features/prototype-author-filter/options/index.tsx index abecb264..9cf601d9 100644 --- a/hub/app/javascript/features/prototype-author-filter/options/index.tsx +++ b/hub/app/javascript/features/prototype-author-filter/options/index.tsx @@ -3,6 +3,7 @@ import type { KeyboardEvent } from "react" import { ActionMenu, Button, TextInput } from "@primer/react-brand" +import { HubSpinner } from "@components/hub-spinner" import type { PrototypeAuthorResults, PrototypeListing } from "@utils/prototype-listing/types" /** Loads author choices only while the menu is mounted and cancels stale searches. */ @@ -121,9 +122,16 @@ export function PrototypeAuthorOptions({
    - ) : !results || (!results.authors.length && query.trim()) ? ( + ) : !results ? ( +
  • +
    + + {copy.loading} +
    +
  • + ) : !results.authors.length && query.trim() ? (
  • -

    {results ? copy.noAuthors : copy.loading}

    +

    {copy.noAuthors}

  • ) : ( results.authors.map((author) => ( diff --git a/hub/app/javascript/features/prototype-author-filter/styles.css b/hub/app/javascript/features/prototype-author-filter/styles.css index 07dfeeba..bced5040 100644 --- a/hub/app/javascript/features/prototype-author-filter/styles.css +++ b/hub/app/javascript/features/prototype-author-filter/styles.css @@ -64,6 +64,14 @@ padding: var(--base-size-8); } +.prototype-author-filter__loading { + display: flex; + align-items: center; + gap: var(--base-size-12); + padding: var(--base-size-8) var(--brand-control-small-paddingInline-condensed); + color: var(--hub-fg-muted); +} + .prototype-author-filter__paging { display: flex; justify-content: center; From d8f9b6c6fadfc90fa080e8c0e68bd8f895ed9683 Mon Sep 17 00:00:00 2001 From: Nishiki Date: Thu, 22 Oct 2026 09:06:07 -0700 Subject: [PATCH 11/11] fix: Copilot pull request finding Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- hub/app/views/repositories/show.json.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hub/app/views/repositories/show.json.props b/hub/app/views/repositories/show.json.props index 7e07cb74..9db67da7 100644 --- a/hub/app/views/repositories/show.json.props +++ b/hub/app/views/repositories/show.json.props @@ -167,7 +167,7 @@ json.prototypes do json.avatarUrl prototype.author.avatar_url json.label t("repositories.show.card_author", login: prototype.author.login) json.ownerLabel "@#{prototype.author.login}" - json.url "https://github.com/#{prototype.author.login}" + json.url "#{Rails.configuration.x.github_url}/#{CGI.escapeURIComponent(prototype.author.login)}" end else json.author do