- {countLabel}
+ {data.prototypesCountLabel}
+ {data.listing.matchingCount > data.prototypes.length
+ ? ` (${data.listing.copy.shown})`
+ : ""}
+
+
-
+
+ {controls.pending ?
{data.listing.copy.updating} : null}
+ {controls.error ? (
+
+ {data.listing.copy.retry}
+
+ }
+ />
+ ) : null}
+ {filtered ? (
+
+
+ {data.listing.copy.clearFilters}
+
+
+ ) : 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 ? (
+
+ {(
+ [
+ [data.listing.previousPath, data.listing.copy.previous],
+ [data.listing.newestPath, data.listing.copy.newest],
+ [data.listing.nextPath, data.listing.copy.next],
+ ] as const
+ ).map(([path, label]) =>
+ path ? (
+ {
+ if (
+ event.button === 0 &&
+ !event.metaKey &&
+ !event.ctrlKey &&
+ !event.shiftKey &&
+ !event.altKey
+ ) {
+ event.preventDefault()
+ event.stopPropagation()
+ controls.onNavigate(path)
+ }
+ }}
+ >
+ {label}
+
+ ) : null,
+ )}
+
+ ) : 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)
- }}
- >
-
}
- onClick={() => {
- setResults(null)
- setFailed(false)
- setOpen(!open)
- }}
- >
- {copy.author}: {listing.authorLabel}
-
-
- {open ? (
-
-
{
- setQuery(event.target.value)
- setCursor(null)
- setResults(null)
- setFailed(false)
- }}
- />
-
-
-
select("me")}
- leadingVisual={listing.author === "me" ? : undefined}
- >
- {copy.me}
-
-
select("")}
- >
- {copy.allAuthors}
-
-
-
- {failed ? (
-
-
{copy.authorsFailed}
-
{
- setFailed(false)
- setRetry((value) => value + 1)
- }}
- >
- {copy.retry}
-
-
- ) : !results ? (
-
{copy.loading}
- ) : !results.authors.length ? (
-
{copy.noAuthors}
- ) : (
- results.authors.map((author) => (
-
select(author.id)}
- >
- @{author.login}
- {author.name ? ` (${author.name})` : ""}
-
- ))
- )}
-
-
- {results?.nextCursor ? (
-
{
- setCursor(results.nextCursor)
- setResults(null)
- input.current?.focus()
- }}
- >
- {copy.moreAuthors}
-
- ) : null}
- {cursor ? (
-
{
- setCursor(null)
- setResults(null)
- setFailed(false)
- input.current?.focus()
- }}
- >
- {copy.firstAuthors}
-
- ) : null}
-
-
- ) : null}
+
)
}
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}
+
{
+ setFailed(false)
+ setRetry((value) => value + 1)
+ input.current?.focus()
+ }}
+ >
+ {copy.retry}
+
+
+
+ ) : !results || !results.authors.length ? (
+
+ {results ? copy.noAuthors : copy.loading}
+
+ ) : (
+ results.authors.map((author) => (
+
+ @{author.login}
+ {author.name ? ` (${author.name})` : ""}
+
+ ))
+ )}
+
+ {results?.nextCursor || cursor ? (
+
+ {results?.nextCursor ? (
+ {
+ setCursor(results.nextCursor)
+ setResults(null)
+ input.current?.focus()
+ }}
+ >
+ {copy.moreAuthors}
+
+ ) : null}
+ {cursor ? (
+ {
+ setCursor(null)
+ setResults(null)
+ setFailed(false)
+ input.current?.focus()
+ }}
+ >
+ {copy.firstAuthors}
+
+ ) : 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 ? (
- 1 ? (
+
- {(
- [
- [data.listing.previousPath, data.listing.copy.previous],
- [data.listing.newestPath, data.listing.copy.newest],
- [data.listing.nextPath, data.listing.copy.next],
- ] as const
- ).map(([path, label]) =>
- path ? (
- ) => {
- if (
- event.button === 0 &&
- !event.metaKey &&
- !event.ctrlKey &&
- !event.shiftKey &&
- !event.altKey
- ) {
- event.preventDefault()
- event.stopPropagation()
- controls.onNavigate(path)
- }
- }}
- >
- {label}
-
- ) : null,
- )}
-
+ 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({