Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 36 additions & 6 deletions hub/app/controllers/concerns/has_repository_workspace.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,16 @@ module HasRepositoryWorkspace
private

# Loads one readable repository and the repository choices for the workspace.
def load_repository_workspace(fresh: false)
def load_repository_workspace(fresh: false, repository_choices: true)
github_id = params[:github_id] || params[:repository_github_id]
@repository = Repository.find_by_route_id!(github_id)
configuration = GithubAppConfiguration.current
configuration = @github_configuration = GithubAppConfiguration.current
@repository_permission = @repository.authorization.permission_for(Current.user, configuration:, fresh:)
if !@repository.authorization.sufficient?(@repository_permission, minimum: :read)
return head :not_found
end

@repositories = authorized_repositories(configuration, including: @repository)
@repositories = repository_choices ? authorized_repositories(configuration, including: @repository) : [ @repository ]
end

# Returns active repositories from a cached list of current GitHub permissions.
Expand All @@ -31,16 +31,46 @@ def authorized_repositories(configuration, including: nil, cache: Rails.cache)
configuration&.cache_key_with_version,
repositories.cache_key_with_version
]
route_ids = cache.fetch(cache_key, expires_in: REPOSITORY_SELECTOR_CACHE_TTL) do
repositories.filter_map do |repository|
if repository == including || repository.authorization.authorized?(Current.user, configuration:)
route_ids = cache.read(cache_key)
if route_ids.nil?
entries = repositories.to_a
payloads = {}
if !LocalDevelopment.enabled? && configuration&.installation_state == "active"
uncached = entries.reject do |repository|
repository == including || repository.authorization.cached_permission(Current.user, cache:).present?
end
if uncached.any?
payloads = begin
configuration.github_client.repositories.permissions \
uncached,
Current.user,
token: configuration.tokens.installation
rescue GithubClient::Error => error
uncached.to_h { |repository| [ repository.github_id, error ] }
end
end
end
route_ids = entries.filter_map do |repository|
permission = if repository == including
"read"
else
repository.authorization.permission_for \
Current.user,
configuration:,
cache:,
payload: payloads[repository.github_id]
end
if repository.authorization.sufficient?(permission, minimum: :read)
repository.route_id
end
rescue GithubClient::Error => error
@repository_navigation_unavailable = true
Rails.logger.warn("Repository navigation permission check failed: #{error.class}")
nil
end
if !@repository_navigation_unavailable
cache.write(cache_key, route_ids, expires_in: REPOSITORY_SELECTOR_CACHE_TTL)
end
end

authorized = if LocalDevelopment.enabled?
Expand Down
38 changes: 27 additions & 11 deletions hub/app/controllers/prototype_management_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ class PrototypeManagementController < SuperglueController

# @route GET /repositories/:repository_github_id/prototypes/manage (repository_prototype_management)
def show
load_repository_workspace
load_repository_workspace(repository_choices: false)
return if performed?
return head :not_found if !@repository.authorization.sufficient?(@repository_permission, minimum: :write)
page = params[:page]
Expand All @@ -15,16 +15,17 @@ def show
@candidate_page = page ? page.to_i : 1
@candidate_page_path = repository_prototype_management_path(@repository.route_id)
@registered_prototypes = @repository.reviewable_prototypes.active.registered.order(:branch_name)
begin
@unregistered_prototypes, @candidate_page_count = @repository.prototype_candidates.available_page \
Current.user,
permission: @repository_permission,
page: @candidate_page
head :not_found if @candidate_page > [ @candidate_page_count, 1 ].max
rescue GithubClient::Error, LocalDevelopment::Error
@candidate_check_failed = true
@unregistered_prototypes = []
@candidate_page_count = 0
@unregistered_prototypes = []
@candidate_page_count = 0
return if !request.format.json?

case params[:props_at]
when "data.management"
load_candidates
when "data.repositoryChoices"
@repositories = authorized_repositories(@github_configuration, including: @repository)
else
head :not_found if params[:props_at]
end
rescue LocalDevelopment::Error => error
response.status = 422
Expand All @@ -34,6 +35,21 @@ def show

private

# Checks available branches independently of the workspace and listed prototypes.
def load_candidates
@unregistered_prototypes, @candidate_page_count = @repository.prototype_candidates.available_page \
Current.user,
permission: @repository_permission,
page: @candidate_page,
configuration: @github_configuration
head :not_found if @candidate_page > [ @candidate_page_count, 1 ].max
rescue GithubClient::Error, LocalDevelopment::Error => error
Rails.logger.warn("Prototype candidate check failed: #{error.class}")
@candidate_check_failed = true
@unregistered_prototypes = []
@candidate_page_count = 0
end

# Does not present an incomplete branch list as authoritative.
def repository_unavailable
head :service_unavailable
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"
import { Application, NavigationContext } from "@thoughtbot/superglue"
import { useContext } from "react"
import { describe, expect, it, vi } from "vitest"

import { store } from "@application/store"
import { HubThemeProvider } from "@hooks/use-hub-theme"
import managementStory from "@pages/prototype-management-page/stories"

import ManagementView from "../../../views/prototype_management/show"
import { buildVisitAndRemote } from "."

describe("async prototype management", () => {
it("mounts the shell and grafts independent section responses through Superglue", async () => {
const data = managementStory.args.data
const path = "/repositories/2001/prototypes/manage"
const initialPage = {
data: {
...data,
sectionLoadId: "first-shell",
liveInbox: undefined,
notificationInbox: data.notificationInbox
? { ...data.notificationInbox, copy: { ...data.notificationInbox.copy } }
: undefined,
repository: null,
management: {
...data.management,
registered: data.management.registered.map((entry) => ({ ...entry })),
unlisted: [],
candidatePageCount: 0,
},
repositoryChoices: {
...data.repositoryChoices,
repositories: data.repositoryChoices.repositories
.slice(0, 1)
.map((repository) => ({ ...repository })),
},
},
componentIdentifier: "prototype_management/show",
assets: [],
defers: [],
fragments: [],
slices: {},
renderedAt: 1,
restoreStrategy: "revisitOnly" as const,
}
let finishBranches!: (response: Response) => void
let finishRepositories!: (response: Response) => void
const branches = new Promise<Response>((resolve) => {
finishBranches = resolve
})
const repositories = new Promise<Response>((resolve) => {
finishRepositories = resolve
})
const fetchMock = vi.fn<typeof fetch>().mockImplementation((input) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
return url.includes("props_at=data.management") ? branches : repositories
})
vi.stubGlobal("fetch", fetchMock)
vi.spyOn(window, "scrollTo").mockImplementation(() => {})
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
render(
<HubThemeProvider>
<Application
baseUrl="http://localhost"
initialPage={initialPage}
path={path}
buildVisitAndRemote={buildVisitAndRemote}
store={store}
mapping={{ "prototype_management/show": ManagementView }}
/>
</HubThemeProvider>,
)
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(screen.getByText(data.management.candidateLoadingLabel)).toBeVisible()
expect(screen.getByRole("button", { name: "Edit details: prototype/welcome" })).toBeEnabled()
expect(screen.queryByText(data.management.emptyLabel)).not.toBeInTheDocument()
fireEvent.click(screen.getByRole("button", { name: "Edit details: prototype/welcome" }))
const dialog = screen.getByRole("dialog", { name: data.management.editDialogHeading })
const name = within(dialog).getByRole("textbox", { name: data.management.nameLabel })
fireEvent.change(name, { target: { value: "Unsaved prototype name" } })

const repositoryResponse = Response.json({
...initialPage,
action: "graft",
path: "data.repositoryChoices",
data: data.repositoryChoices,
})
Object.defineProperty(repositoryResponse, "url", {
value: `http://localhost${path}?props_at=data.repositoryChoices`,
})
await act(async () => {
finishRepositories(repositoryResponse)
await repositories
})
await waitFor(() =>
expect(screen.queryByText(data.repositoryChoices.loadingLabel)).not.toBeInTheDocument(),
)
fireEvent.click(screen.getByRole("button", { name: data.repositorySelectorLabel }))
expect(screen.getByRole("link", { name: "github/empty" })).toBeInTheDocument()
expect(screen.getByText(data.management.candidateLoadingLabel)).toBeVisible()
expect(name).toHaveValue("Unsaved prototype name")

const branchResponse = Response.json({
...initialPage,
action: "graft",
path: "data.management",
data: data.management,
})
Object.defineProperty(branchResponse, "url", {
value: `http://localhost${path}?props_at=data.management`,
})
await act(async () => {
finishBranches(branchResponse)
await branches
})
await waitFor(() =>
expect(screen.getByRole("button", { name: "Add to Hub: prototype/new" })).toBeEnabled(),
)
expect(screen.queryByText(data.management.candidateLoadingLabel)).not.toBeInTheDocument()
expect(screen.getByRole("dialog", { name: data.management.editDialogHeading })).toBe(dialog)
expect(name).toHaveValue("Unsaved prototype name")
expect(fetchMock).toHaveBeenCalledTimes(2)
})

it("reloads both sections once after a full visit replaces the shell at the same URL", async () => {
const data = structuredClone(managementStory.args.data)
const path = "/repositories/2001/prototypes/manage"
const shell = {
data: {
...data,
sectionLoadId: "before-refresh",
liveInbox: undefined,
notificationInbox: data.notificationInbox
? { ...data.notificationInbox, copy: { ...data.notificationInbox.copy } }
: undefined,
repository: null,
management: {
...data.management,
registered: data.management.registered.map((entry) => ({ ...entry })),
unlisted: [],
candidatePageCount: 0,
},
repositoryChoices: {
...data.repositoryChoices,
repositories: data.repositoryChoices.repositories
.slice(0, 1)
.map((repository) => ({ ...repository })),
},
},
componentIdentifier: "prototype_management/show",
assets: [],
defers: [],
fragments: [],
slices: {},
renderedAt: 1,
restoreStrategy: "revisitOnly" as const,
}
const fetchMock = vi.fn<typeof fetch>().mockImplementation((input) => {
const url = new URL(
typeof input === "string" ? input : input instanceof URL ? input.href : input.url,
"http://localhost",
)
const section = url.searchParams.get("props_at")
const payload = section
? {
...shell,
action: "graft",
path: section,
data: section === "data.management" ? data.management : data.repositoryChoices,
}
: { ...shell, data: { ...shell.data, sectionLoadId: "after-refresh" } }
const response = Response.json(payload)
Object.defineProperty(response, "url", { value: url.href })
return Promise.resolve(response)
})
vi.stubGlobal("fetch", fetchMock)
vi.spyOn(window, "scrollTo").mockImplementation(() => {})
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
function RefreshableManagement() {
const { visit } = useContext(NavigationContext)
return (
<>
<button onClick={() => void visit(path, {})}>Refresh management</button>
<ManagementView />
</>
)
}
render(
<HubThemeProvider>
<Application
baseUrl="http://localhost"
initialPage={shell}
path={path}
buildVisitAndRemote={buildVisitAndRemote}
store={store}
mapping={{ "prototype_management/show": RefreshableManagement }}
/>
</HubThemeProvider>,
)
await waitFor(() =>
expect(screen.getByRole("button", { name: "Add to Hub: prototype/new" })).toBeEnabled(),
)
expect(fetchMock).toHaveBeenCalledTimes(2)

fireEvent.click(screen.getByRole("button", { name: "Refresh management" }))

await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(5))
await waitFor(() =>
expect(screen.getByRole("button", { name: "Add to Hub: prototype/new" })).toBeEnabled(),
)
expect(screen.queryByText(data.management.emptyLabel)).not.toBeInTheDocument()
const requests = fetchMock.mock.calls.map(([input]) =>
typeof input === "string" ? input : input instanceof URL ? input.href : input.url,
)
expect(requests.filter((url) => url.includes("props_at=data.management"))).toHaveLength(2)
expect(requests.filter((url) => url.includes("props_at=data.repositoryChoices"))).toHaveLength(
2,
)
expect(requests.filter((url) => !url.includes("props_at="))).toHaveLength(1)
})
})
6 changes: 6 additions & 0 deletions hub/app/javascript/application/sidebar-inbox-views.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { RepositoryWorkspaceData } from "@features/repository-workspace/typ
import { HubWorkspace } from "@features/hub-workspace"
import type { HubWorkspaceData } from "@features/hub-workspace/types"
import repositoryStories from "@pages/repositories-show-page/stories"
import managementStories from "@pages/prototype-management-page/stories"
import inboxStories from "@pages/notifications-page/stories"

import AnalyticsView from "../../views/analytics/show"
Expand Down Expand Up @@ -78,6 +79,8 @@ vi.mock("@hooks/use-page-data", () => ({
usePageData: () => {
const data = {
...repositoryStories.args.data,
management: managementStories.args.data.management,
repositoryChoices: managementStories.args.data.repositoryChoices,
notificationInbox: inboxStories.args.data.notificationInbox,
notificationsPath: "/notifications",
notificationsLabel: "Inbox",
Expand All @@ -96,6 +99,9 @@ vi.mock("@hooks/use-page-data", () => ({
}
},
}))
vi.mock("@hooks/use-page-section", () => ({
usePageSection: () => ({ loading: false, failed: false, retry: () => {} }),
}))
vi.mock("@hooks/use-hub-theme", () => ({
useHubTheme: () => ({ resolvedColorMode: "light" }),
useOptionalHubTheme: () => null,
Expand Down
Loading
Loading