diff --git a/hub/app/controllers/builds_controller.rb b/hub/app/controllers/builds_controller.rb index 2f858009..a78d248c 100644 --- a/hub/app/controllers/builds_controller.rb +++ b/hub/app/controllers/builds_controller.rb @@ -11,6 +11,7 @@ def index load_prototype return if performed? + @history_build = history_build before = params[:before] before_at = params[:before_at] return head :not_found if before_at && !before @@ -27,11 +28,17 @@ def index return head :not_found if before && page_builds.empty? @builds = page_builds.first(BUILDS_PER_PAGE) - @newest_page_path = repository_prototype_builds_path(@repository.route_id, @prototype) if before + if before + @newest_page_path = repository_prototype_builds_path \ + @repository.route_id, + @prototype, + **history_parameters + end if page_builds.size > BUILDS_PER_PAGE @next_page_path = repository_prototype_builds_path \ @repository.route_id, @prototype, + **history_parameters, before: @builds.last.number, before_at: @builds.last.chronological_at.iso8601(6) end @@ -65,7 +72,8 @@ def load_prototype def prepare_review_header @latest_build = @prototype.current_build || @prototype.builds.chronological.first - @selected_build = @prototype.builds.where(state: "successful").chronological.first + @selected_build = @history_build || + @prototype.builds.where(state: "successful").chronological.first @build_options = @prototype.builds.chronological.to_a @can_manage_share_links = @prototype.share_links_manageable_by? \ Current.user, @@ -90,6 +98,19 @@ def valid_time?(value) false end + def history_build + number = params[:build] + return if number.nil? || (number.is_a?(ActionController::Parameters) && number.empty?) + + raise ActiveRecord::RecordNotFound if !valid_build_number?(number) + + @prototype.builds.where(state: "successful").find_by!(number: number.to_i) + end + + def history_parameters + @history_build ? { build: @history_build.number } : {} + end + # Adds the captured timestamp required by chronological pagination. def redirect_legacy_cursor(number) return head :not_found if !valid_build_number?(number) @@ -98,6 +119,7 @@ def redirect_legacy_cursor(number) redirect_to repository_prototype_builds_path \ @repository.route_id, @prototype, + **history_parameters, before: build.number, before_at: build.chronological_at.iso8601(6) end diff --git a/hub/app/controllers/concerns/has_share_link_context.rb b/hub/app/controllers/concerns/has_share_link_context.rb index d5bdb428..6ef66e21 100644 --- a/hub/app/controllers/concerns/has_share_link_context.rb +++ b/hub/app/controllers/concerns/has_share_link_context.rb @@ -61,9 +61,10 @@ def create_share_link build = @prototype.builds.where(state: "successful").find_by! \ number: positive_build_number(attributes[:build_number]) if attributes[:expiration] == "never" + return_to = Current.canvas_grant ? root_path : management_path return if !require_fresh_authorization( purpose: "never_expiring_share", - return_to: repository_prototype_share_links_path(@repository.route_id, @prototype, build: build.number) + return_to: ) end diff --git a/hub/app/controllers/share_links_controller.rb b/hub/app/controllers/share_links_controller.rb index 09adea02..dbb19089 100644 --- a/hub/app/controllers/share_links_controller.rb +++ b/hub/app/controllers/share_links_controller.rb @@ -6,6 +6,7 @@ class ShareLinksController < SuperglueController before_action :load_share_link_context before_action :require_public_sharing, only: %i[create destroy] before_action :validate_control_parameters, only: [ :create, :destroy ] + before_action :validate_management_build, only: %i[create destroy] rescue_from GithubClient::Error, with: :repository_unavailable rescue_from SharingSetting::Disabled, SharingSetting::Changed, with: :public_sharing_disabled @@ -23,7 +24,7 @@ def create share_link = create_share_link return if performed? - redirect_to management_path(share_link.build.number), status: :see_other + redirect_to management_path, status: :see_other rescue ShareLink::InvalidExpiration, ActiveRecord::RecordInvalid => error invalid_share_link(error) end @@ -32,9 +33,8 @@ def create # @route DELETE /repositories/:repository_github_id/prototypes/:prototype_id/share_links/:id (repository_prototype_share_link) def destroy share_link = @prototype.share_links.find(params[:id]) - build_number = share_link.build.number share_link.destroy! - redirect_to management_path(build_number), status: :see_other + redirect_to management_path, status: :see_other end private @@ -78,13 +78,21 @@ def selected_build builds.find_by!(number: positive_build_number(params[:build])) end - # Returns the management page for the selected Build. - def management_path(build_number) + # Returns to the same default or explicitly selected management view. + def management_path repository_prototype_share_links_path \ @repository.route_id, @prototype, **@control_parameters, - build: build_number + **(@management_build_number ? { build: @management_build_number } : {}) + end + + def validate_management_build + value = params[:management_build] + return if value.nil? + + number = positive_build_number(value) + @management_build_number = @prototype.builds.where(state: "successful").find_by!(number:).number end def validate_control_parameters @@ -114,7 +122,7 @@ def control_parameters def invalid_share_link(error) record_security_audit(outcome: "failed") redirect_to \ - management_path(positive_build_number(params.dig(:share_link, :build_number))), + management_path, alert: share_link_errors(error).to_sentence, status: :see_other end diff --git a/hub/app/javascript/features/prototype-workspace-header/stories.tsx b/hub/app/javascript/features/prototype-workspace-header/stories.tsx index 641e2ed3..2d30c747 100644 --- a/hub/app/javascript/features/prototype-workspace-header/stories.tsx +++ b/hub/app/javascript/features/prototype-workspace-header/stories.tsx @@ -14,7 +14,7 @@ const data: PrototypeWorkspaceHeaderData = { url: "https://github.com/octocat", }, branchName: "prototype/welcome", - branchPath: "/repositories/2001/prototypes/1?build=1", + branchPath: "/repositories/2001/prototypes/1", buildOptions: [ { number: 1, @@ -35,7 +35,7 @@ const data: PrototypeWorkspaceHeaderData = { number: 1, }, shareLinksLabel: "Share links", - shareLinksPath: "/repositories/2001/prototypes/1/share_links?build=1", + shareLinksPath: "/repositories/2001/prototypes/1/share_links", useDarkThemeLabel: "Use dark theme", useLightThemeLabel: "Use light theme", } diff --git a/hub/app/javascript/features/share-link-item/index.tsx b/hub/app/javascript/features/share-link-item/index.tsx index e6c8f541..8a0c6bf2 100644 --- a/hub/app/javascript/features/share-link-item/index.tsx +++ b/hub/app/javascript/features/share-link-item/index.tsx @@ -15,6 +15,7 @@ export function ShareLinkItem({ deleteLabel, openLabel, controlQuery = "", + managementBuildNumber, }: ShareLinkItemProps) { return ( + {managementBuildNumber ? ( + + ) : null} { it("refreshes the copied viewer URL when controls change without losing its page destination", () => { - window.history.replaceState( - {}, - "", - "/repositories/2001/prototypes/1?build=2&return_path=%2Fsettings", - ) + window.history.replaceState({}, "", "/repositories/2001/prototypes/1?return_path=%2Fsettings") const { result } = renderHook(() => usePrototypeViewerUrl({ selectedBuild, route: null, initiallyMinimized: false }), ) @@ -29,6 +25,7 @@ describe("usePrototypeViewerUrl()", () => { const url = new URL(result.current.viewerUrl) expect(url.searchParams.get("rc.page.title")).toBe('"Configured"') expect(url.searchParams.get("return_path")).toBe("/settings") + expect(url.searchParams.has("build")).toBe(false) expect(window.location.href).toBe(result.current.viewerUrl) }) @@ -36,19 +33,20 @@ describe("usePrototypeViewerUrl()", () => { window.history.replaceState( { superglue: "saved-page" }, "", - "/repositories/2001/prototypes/1?source=gallery", + "/repositories/2001/prototypes/1?source=gallery#hub", ) }) - it("keeps the Hub address and history state while recording the exact Build and start page", () => { + it("keeps the default Hub address unpinned while recording the start page", () => { const { result } = renderHook(() => usePrototypeViewerUrl({ selectedBuild, route: null, initiallyMinimized: false }), ) const url = new URL(result.current.viewerUrl) expect(url.pathname).toBe("/repositories/2001/prototypes/1") expect(url.searchParams.get("source")).toBe("gallery") - expect(url.searchParams.get("build")).toBe("2") + expect(url.searchParams.has("build")).toBe(false) expect(url.searchParams.get("return_path")).toBe("/copilot?tab=all#overview") + expect(url.hash).toBe("#hub") expect(window.location.href).toBe(result.current.viewerUrl) expect(window.history.state).toEqual({ superglue: "saved-page" }) }) @@ -71,8 +69,46 @@ describe("usePrototypeViewerUrl()", () => { expect(new URL(result.current.viewerUrl).searchParams.get("return_path")).toBe( "/copilot/task/123?filter=a%26b&next=%2Fsettings#/details?tab=activity", ) + expect(new URL(result.current.viewerUrl).searchParams.has("build")).toBe(false) + expect(new URL(result.current.viewerUrl).hash).toBe("#hub") expect(window.location.href).toBe(result.current.viewerUrl) expect(window.history.length).toBe(length) + expect(window.history.state).toEqual({ superglue: "saved-page" }) + }) + + it("keeps an explicitly selected latest Build pinned through viewer updates", () => { + window.history.replaceState( + { superglue: "pinned-page" }, + "", + "/repositories/2001/prototypes/1?build=2&source=gallery#hub", + ) + const initialProps: { route: ReviewRoute | null } = { route: null } + const { result, rerender } = renderHook( + ({ route }: typeof initialProps) => + usePrototypeViewerUrl({ selectedBuild, route, initiallyMinimized: false }), + { initialProps }, + ) + expect(new URL(result.current.viewerUrl).searchParams.get("build")).toBe("2") + + rerender({ route: { pathname: "/settings", search: "?tab=profile", hash: "#details" } }) + act(() => result.current.setMinimized(true)) + act(() => { + const url = new URL(window.location.href) + url.searchParams.set("rc.page.title", '"Configured"') + window.history.replaceState(window.history.state, "", url) + window.dispatchEvent(new Event("replica:controls:url")) + }) + const url = new URL(result.current.viewerUrl) + expect(url.searchParams.get("build")).toBe("2") + expect(url.searchParams.get("return_path")).toBe("/settings?tab=profile#details") + expect(url.searchParams.get("rc.page.title")).toBe('"Configured"') + expect(url.searchParams.get("view")).toBe("minimized") + expect(url.searchParams.get("source")).toBe("gallery") + expect(url.hash).toBe("#hub") + expect(window.location.href).toBe(result.current.viewerUrl) + expect(window.history.state).toEqual({ superglue: "pinned-page" }) + act(() => result.current.setMinimized(false)) + expect(new URL(result.current.viewerUrl).searchParams.get("build")).toBe("2") }) it("shares minimized mode and removes only that mode when maximizing", () => { @@ -89,9 +125,11 @@ describe("usePrototypeViewerUrl()", () => { ) act(() => result.current.setMinimized(true)) expect(new URL(window.location.href).searchParams.get("view")).toBe("minimized") + expect(new URL(result.current.viewerUrl).searchParams.has("build")).toBe(false) }) it("keeps the last page while the same iframe reloads but uses the next Build's start page", () => { + window.history.replaceState({}, "", "/repositories/2001/prototypes/1?build=2") const initialProps: { selectedBuild: typeof selectedBuild route: ReviewRoute | null @@ -115,6 +153,49 @@ describe("usePrototypeViewerUrl()", () => { expect(new URL(result.current.viewerUrl).searchParams.get("return_path")).toBe("/new") }) + it("keeps a reopened default destination when a newer Build is selected", () => { + const destination = "/settings?tab=profile#details" + window.history.replaceState( + {}, + "", + `/repositories/2001/prototypes/1?return_path=${encodeURIComponent(destination)}`, + ) + const { result, rerender } = renderHook( + ({ selectedBuild }: { selectedBuild: { number: number; previewUrl: string } }) => + usePrototypeViewerUrl({ selectedBuild, route: null, initiallyMinimized: false }), + { initialProps: { selectedBuild } }, + ) + rerender({ + selectedBuild: { number: 3, previewUrl: "http://b13.preview.replica.localhost:19500/new" }, + }) + const url = new URL(result.current.viewerUrl) + expect(url.searchParams.get("return_path")).toBe(destination) + expect(url.searchParams.has("build")).toBe(false) + }) + + it("keeps a restored history destination unpinned without creating a history entry", () => { + const { result } = renderHook(() => + usePrototypeViewerUrl({ selectedBuild, route: null, initiallyMinimized: false }), + ) + const length = window.history.length + act(() => { + window.history.replaceState( + { superglue: "restored-page" }, + "", + "/repositories/2001/prototypes/1?return_path=%2Fsettings%3Ftab%3Dprofile%23details&rc.page.title=%22Restored%22#hub", + ) + window.dispatchEvent(new PopStateEvent("popstate")) + }) + const url = new URL(result.current.viewerUrl) + expect(url.searchParams.get("return_path")).toBe("/settings?tab=profile#details") + expect(url.searchParams.get("rc.page.title")).toBe('"Restored"') + expect(url.searchParams.has("build")).toBe(false) + expect(url.hash).toBe("#hub") + expect(window.location.href).toBe(result.current.viewerUrl) + expect(window.history.state).toEqual({ superglue: "restored-page" }) + expect(window.history.length).toBe(length) + }) + it("does not change the browser address without a successful Build", () => { const original = window.location.href const { result } = renderHook(() => @@ -132,7 +213,7 @@ describe("usePrototypeViewerUrl()", () => { "/repositories/1/replica?build=99&before=invalid&return_path=%2F%23%2Fabout&view=minimized", ) const { result } = renderHook(() => - usePrototypeViewerUrl({ route: null, initiallyMinimized: true, pinBuild: false }), + usePrototypeViewerUrl({ route: null, initiallyMinimized: true, buildSelection: "ignore" }), ) const url = new URL(result.current.viewerUrl) expect(result.current.minimized).toBe(false) @@ -157,7 +238,7 @@ describe("usePrototypeViewerUrl()", () => { selectedBuild, route, initiallyMinimized: false, - pinBuild: false, + buildSelection: "ignore", }), { initialProps }, ) @@ -187,7 +268,7 @@ describe("usePrototypeViewerUrl()", () => { selectedBuild, route, initiallyMinimized: true, - pinBuild: false, + buildSelection: "ignore", }), { initialProps: { route: null as ReviewRoute | null } }, ) diff --git a/hub/app/javascript/hooks/use-prototype-viewer-url/index.ts b/hub/app/javascript/hooks/use-prototype-viewer-url/index.ts index 29a94a99..1245c7a8 100644 --- a/hub/app/javascript/hooks/use-prototype-viewer-url/index.ts +++ b/hub/app/javascript/hooks/use-prototype-viewer-url/index.ts @@ -7,12 +7,12 @@ export function usePrototypeViewerUrl({ selectedBuild, route, initiallyMinimized, - pinBuild = true, + buildSelection = "preserve", }: { selectedBuild?: { number: number; previewUrl: string } route: ReviewRoute | null initiallyMinimized: boolean - pinBuild?: boolean + buildSelection?: "preserve" | "ignore" }) { const [, refreshUrl] = useState(0) useEffect(() => { @@ -35,7 +35,7 @@ export function usePrototypeViewerUrl({ (view.previewUrl === selectedBuild?.previewUrl ? view.minimized : initiallyMinimized) const url = new URL(window.location.href) - if (!pinBuild) { + if (buildSelection === "ignore") { url.searchParams.delete("build") url.searchParams.delete("before") } @@ -43,13 +43,14 @@ export function usePrototypeViewerUrl({ if (selectedBuild) { const preview = new URL(selectedBuild.previewUrl) const previousPath = - !pinBuild || url.searchParams.get("build") === String(selectedBuild.number) + buildSelection === "ignore" || + !url.searchParams.has("build") || + url.searchParams.get("build") === String(selectedBuild.number) ? url.searchParams.get("return_path") : null const destination = route ? `${route.pathname}${route.search}${route.hash}` : (previousPath ?? `${preview.pathname}${preview.search}${preview.hash}`) - if (pinBuild) url.searchParams.set("build", String(selectedBuild.number)) url.searchParams.set("return_path", destination) if (minimized) url.searchParams.set("view", "minimized") else url.searchParams.delete("view") @@ -57,10 +58,10 @@ export function usePrototypeViewerUrl({ const viewerUrl = url.toString() useEffect(() => { - if ((selectedBuild || !pinBuild) && window.location.href !== viewerUrl) { + if ((selectedBuild || buildSelection === "ignore") && window.location.href !== viewerUrl) { window.history.replaceState(window.history.state, "", viewerUrl) } - }, [selectedBuild, pinBuild, viewerUrl]) + }, [selectedBuild, buildSelection, viewerUrl]) return { viewerUrl, diff --git a/hub/app/javascript/pages/builds-index-page/stories.tsx b/hub/app/javascript/pages/builds-index-page/stories.tsx index e5e65fc1..5976543b 100644 --- a/hub/app/javascript/pages/builds-index-page/stories.tsx +++ b/hub/app/javascript/pages/builds-index-page/stories.tsx @@ -10,7 +10,7 @@ const data: BuildsIndexPageData = { ...prototypeWorkspaceData, builds: [], buildsHeading: "History", - closePath: "/repositories/2001/prototypes/1?build=1", + closePath: "/repositories/2001/prototypes/1", emptyLabel: "No Builds have been recorded.", pageTitle: "prototype/welcome Builds", nextPageLabel: "Older Builds", @@ -18,7 +18,7 @@ const data: BuildsIndexPageData = { newestPageLabel: "Newest Builds", newestPagePath: null, shareLinksLabel: "Share links", - shareLinksPath: "/repositories/2001/prototypes/1/share_links?build=1", + shareLinksPath: "/repositories/2001/prototypes/1/share_links", } const meta = { diff --git a/hub/app/javascript/pages/prototypes-show-page/index.test.tsx b/hub/app/javascript/pages/prototypes-show-page/index.test.tsx index cc8e3156..4b6c0fa7 100644 --- a/hub/app/javascript/pages/prototypes-show-page/index.test.tsx +++ b/hub/app/javascript/pages/prototypes-show-page/index.test.tsx @@ -139,6 +139,7 @@ const PROTOTYPES_SHOW_PAGE_DATA: PrototypesShowPageData = { }, authenticityToken: "token", branchName: "prototype/welcome", + buildPinned: false, prototypeName: "Welcome prototype", prototypeDescription: "A simpler welcome experience for repository contributors.", showPrototypeInfo: false, @@ -202,7 +203,7 @@ const PROTOTYPES_SHOW_PAGE_DATA: PrototypesShowPageData = { }, ], buildSelectionLabel: "Choose build", - branchPath: "/repositories/2001/prototypes/1?build=2", + branchPath: "/repositories/2001/prototypes/1", closeLabel: "Close", collapseNavigationLabel: "Collapse sidebar", currentUser: { avatarUrl: null, login: "octocat", name: "Octocat" }, @@ -238,7 +239,7 @@ const PROTOTYPES_SHOW_PAGE_DATA: PrototypesShowPageData = { repositorySelectorLabel: "Choose repository. github/prototype selected", reviewCommentsPath: "/repositories/2001/prototypes/1/review_comments", shareLinksLabel: "Share links", - shareLinksPath: "/repositories/2001/prototypes/1/share_links?build=2", + shareLinksPath: "/repositories/2001/prototypes/1/share_links", reviewComments: { bodyMaximumBytes: 10_000, copy: { @@ -434,6 +435,7 @@ function TestPage({ alert, onOpenApp, detailsEditPath = PROTOTYPES_SHOW_PAGE_DATA.detailsEditPath, + buildPinned = PROTOTYPES_SHOW_PAGE_DATA.buildPinned, selectedBuild = PROTOTYPES_SHOW_PAGE_DATA.selectedBuild, startPage = PROTOTYPES_SHOW_PAGE_DATA.startPage, prototypeDescription = PROTOTYPES_SHOW_PAGE_DATA.prototypeDescription, @@ -445,6 +447,7 @@ function TestPage({ alert?: string onOpenApp?: (url: string) => void detailsEditPath?: string | null + buildPinned?: boolean selectedBuild?: PrototypesShowPageData["selectedBuild"] | null startPage?: string | null prototypeDescription?: string | null @@ -474,6 +477,7 @@ function TestPage({ data={{ ...PROTOTYPES_SHOW_PAGE_DATA, detailsEditPath: detailsEditPath ?? undefined, + buildPinned, selectedBuild: selectedBuild ?? undefined, startPage, prototypeDescription, @@ -541,7 +545,7 @@ describe("PrototypesShowPage()", () => { }) beforeEach(() => { - window.history.replaceState({}, "", "/repositories/2001/prototypes/1?build=2") + window.history.replaceState({}, "", "/repositories/2001/prototypes/1") BRIDGE.route = { pathname: "/", search: "", hash: "" } vi.stubGlobal( "ResizeObserver", @@ -637,9 +641,10 @@ describe("PrototypesShowPage()", () => { }) it("lets the owner edit details without leaving the selected build", () => { + window.history.replaceState({}, "", "/repositories/2001/prototypes/1?build=2") render( - + , ) @@ -735,6 +740,22 @@ describe("PrototypesShowPage()", () => { }, ) + it("does not pin the displayed Build when editing details from the default view", () => { + render( + + + , + ) + fireEvent.click( + within(screen.getByRole("dialog", { name: "Welcome prototype" })).getByRole("button", { + name: "Edit details", + }), + ) + const form = document.querySelector("#prototype-description-edit")! + expect(new FormData(form).get("return_to")).toBe("review") + expect(new FormData(form).has("build")).toBe(false) + }) + it("allows setting and clearing the start page in the review edit form", () => { render( { const addressBar = document.querySelector(".review-shell__address-bar") const frame = screen.getByTitle("prototype/welcome, build 2 preview") const url = window.location.href + expect(new URL(url).searchParams.has("build")).toBe(false) expect(addressBar).toBeInTheDocument() expect(addressBar).toContainElement(screen.getByRole("textbox", { name: "Preview URL" })) expect( @@ -1398,7 +1420,7 @@ describe("PrototypesShowPage()", () => { expect(screen.getAllByRole("heading", { name: "Welcome prototype" }).length).toBeGreaterThan(0) expect(screen.getByRole("link", { name: "Welcome prototype" })).toHaveAttribute( "href", - "/repositories/2001/prototypes/1?build=2", + "/repositories/2001/prototypes/1", ) expect(screen.getByRole("link", { name: "Welcome prototype" })).toHaveAttribute( "data-sg-visit", @@ -1509,7 +1531,7 @@ describe("PrototypesShowPage()", () => { fireEvent.click(moreActions) const menu = screen.getByRole("menu", { name: "More actions" }) for (const [label, path] of [ - ["Share links", "/repositories/2001/prototypes/1/share_links?build=2"], + ["Share links", "/repositories/2001/prototypes/1/share_links"], ["History", "/repositories/2001/prototypes/1/builds"], ["Build details", "/repositories/2001/prototypes/1/builds/2"], ]) { @@ -1518,7 +1540,7 @@ describe("PrototypesShowPage()", () => { ).toHaveAttribute("href", path) } fireEvent.click(within(menu).getByRole("menuitem", { name: "Share links" }).querySelector("a")!) - expect(visit).toHaveBeenLastCalledWith("/repositories/2001/prototypes/1/share_links?build=2", { + expect(visit).toHaveBeenLastCalledWith("/repositories/2001/prototypes/1/share_links", { method: "GET", dataset: { sgVisit: "true" }, }) diff --git a/hub/app/javascript/pages/prototypes-show-page/prototype-description-dialog/index.tsx b/hub/app/javascript/pages/prototypes-show-page/prototype-description-dialog/index.tsx index d07c61b4..e9474e0e 100644 --- a/hub/app/javascript/pages/prototypes-show-page/prototype-description-dialog/index.tsx +++ b/hub/app/javascript/pages/prototypes-show-page/prototype-description-dialog/index.tsx @@ -140,7 +140,7 @@ export function PrototypeDescriptionDialog({ - {data.selectedBuild ? ( + {data.buildPinned && data.selectedBuild ? ( ) : null} diff --git a/hub/app/javascript/pages/prototypes-show-page/stories.tsx b/hub/app/javascript/pages/prototypes-show-page/stories.tsx index 283a307d..4ec7f4dc 100644 --- a/hub/app/javascript/pages/prototypes-show-page/stories.tsx +++ b/hub/app/javascript/pages/prototypes-show-page/stories.tsx @@ -77,7 +77,8 @@ const data: PrototypesShowPageData = { }, ], buildSelectionLabel: "Choose build", - branchPath: "/repositories/2001/prototypes/1?build=1", + branchPath: "/repositories/2001/prototypes/1", + buildPinned: false, closeLabel: "Close", collapseNavigationLabel: "Collapse sidebar", emptyHeading: "No successful Build yet", @@ -110,7 +111,7 @@ const data: PrototypesShowPageData = { repositorySelectorLabel: "Choose repository. github/prototype selected", reviewCommentsPath: "/repositories/2001/prototypes/1/review_comments", shareLinksLabel: "Share links", - shareLinksPath: "/repositories/2001/prototypes/1/share_links?build=1", + shareLinksPath: "/repositories/2001/prototypes/1/share_links", reviewComments: { bodyMaximumBytes: 10_000, copy: reviewCommentsCopy, diff --git a/hub/app/javascript/pages/prototypes-show-page/types.ts b/hub/app/javascript/pages/prototypes-show-page/types.ts index d5fcad6b..f8263952 100644 --- a/hub/app/javascript/pages/prototypes-show-page/types.ts +++ b/hub/app/javascript/pages/prototypes-show-page/types.ts @@ -7,6 +7,7 @@ import type { usePrototypeReview } from "@hooks/use-prototype-review" import type { ReviewCommentsCopy } from "@utils/review-comment/types" export interface PrototypesShowPageData extends PrototypeWorkspaceData { + buildPinned: boolean mentionCandidatesPath?: string mentionCopy?: MentionSource["copy"] initialCommentId?: number diff --git a/hub/app/javascript/pages/replica-browse-page/index.test.tsx b/hub/app/javascript/pages/replica-browse-page/index.test.tsx index 127e9dce..bc4baeae 100644 --- a/hub/app/javascript/pages/replica-browse-page/index.test.tsx +++ b/hub/app/javascript/pages/replica-browse-page/index.test.tsx @@ -1090,7 +1090,11 @@ describe("ReplicaBrowsesShowView()", () => { ) it("does not minimize a direct link before a successful Build exists", () => { - window.history.replaceState(null, "", "/repositories/2001/replica?view=minimized") + window.history.replaceState( + null, + "", + "/repositories/2001/replica?view=minimized&build=99&before=invalid", + ) vi.mocked(usePageData).mockReturnValue({ ...replicaBrowseData, selectedBuild: undefined, @@ -1108,5 +1112,7 @@ describe("ReplicaBrowsesShowView()", () => { expect(screen.getByRole("heading", { name: "No replica build yet" })).toBeInTheDocument() expect(screen.queryByRole("toolbar")).not.toBeInTheDocument() expect(screen.queryByRole("button", { name: /minimize|maximize/i })).not.toBeInTheDocument() + expect(new URL(window.location.href).searchParams.has("build")).toBe(false) + expect(new URL(window.location.href).searchParams.has("before")).toBe(false) }) }) diff --git a/hub/app/javascript/pages/share-links-index-page/index.test.tsx b/hub/app/javascript/pages/share-links-index-page/index.test.tsx index 437d067c..cee01fd3 100644 --- a/hub/app/javascript/pages/share-links-index-page/index.test.tsx +++ b/hub/app/javascript/pages/share-links-index-page/index.test.tsx @@ -17,6 +17,7 @@ const PAGE_DATA: ShareLinksIndexPageData = { ...story.args.data.form, buildLabel: "Build 2", }, + managementBuildNumber: 2, buildOptions: [ { number: 2, @@ -161,9 +162,19 @@ describe("ShareLinksIndexPage()", () => { expect(form?.querySelector('input[name="authenticity_token"]')).toHaveValue( PAGE_DATA.authenticityToken, ) + expect(form?.querySelector('input[name="management_build"]')).toHaveValue("2") expect(screen.getByRole("button", { name: "Create share link" })).toBeInTheDocument() }) + it("omits management selection from every default-view mutation form", () => { + const { container } = render( + , + { wrapper: HubThemeProvider }, + ) + + expect(container.querySelectorAll('input[name="management_build"]')).toHaveLength(0) + }) + it("shows verification success and leaves creation to the user", () => { const notice = "GitHub authorization succeeded for this connection only. Return to the action and submit it again within five minutes." @@ -224,6 +235,7 @@ describe("ShareLinksIndexPage()", () => { expect(form?.querySelector('input[name="authenticity_token"]')).toHaveValue( PAGE_DATA.authenticityToken, ) + expect(form?.querySelector('input[name="management_build"]')).toHaveValue("2") expect(fireEvent.submit(form!)).toBe(false) expect(fireEvent.submit(form!)).toBe(true) expect(confirm).toHaveBeenCalledWith(link.deletePrompt) diff --git a/hub/app/javascript/pages/share-links-index-page/index.tsx b/hub/app/javascript/pages/share-links-index-page/index.tsx index 262382ab..c195d974 100644 --- a/hub/app/javascript/pages/share-links-index-page/index.tsx +++ b/hub/app/javascript/pages/share-links-index-page/index.tsx @@ -44,6 +44,13 @@ export function ShareLinksIndexPage({ <> + {data.managementBuildNumber ? ( + + ) : null} "application/json" } + pinned_review = JSON.parse(response.body).fetch("data") + pinned_history_path = repository_prototype_builds_path \ + repository.github_id, + prototype, + build: successful.number + assert_equal pinned_history_path, pinned_review.fetch("historyPath") + + get pinned_history_path, headers: { "Accept" => "application/json" } + pinned_history = JSON.parse(response.body).fetch("data") + assert_equal successful.number, pinned_history.fetch("selectedBuild").fetch("number") + assert_equal \ + repository_prototype_path(repository.github_id, prototype, build: successful.number), + pinned_history.fetch("closePath") + get repository_prototype_build_path(repository.github_id, prototype, successful.number), headers: { "Accept" => "application/json" } @@ -297,9 +317,13 @@ class GalleryTest < ActionDispatch::IntegrationTest details = JSON.parse(response.body).fetch("data") assert_equal repository_new_prototype_path(repository.github_id), details.fetch("newPrototypePath") assert_equal successful.number, details.fetch("selectedBuild").fetch("number") - assert_equal repository_prototype_path(repository.github_id, prototype, build: successful.number), + assert_equal \ + repository_prototype_path(repository.github_id, prototype), details.fetch("branchPath") - assert_equal details.fetch("branchPath"), details.fetch("closePath") + assert_equal \ + repository_prototype_path(repository.github_id, prototype, build: successful.number), + details.fetch("closePath") + assert_equal details.fetch("closePath"), details.fetch("previewPath") assert_equal %w[detailsLabel detailsPath number], details.fetch("selectedBuild").keys.sort get repository_prototype_build_path(repository.github_id, prototype, failed.number), @@ -325,6 +349,9 @@ class GalleryTest < ActionDispatch::IntegrationTest assert_response :success assert_includes response.body, PreviewOrigin.current.url(selected.id) + data = JSON.parse(response.body).fetch("data") + assert_equal false, data.fetch("buildPinned") + assert_equal repository_prototype_path(repository.github_id, prototype), data.fetch("branchPath") assert_equal "The latest update failed. You're viewing an earlier version.", JSON.parse(response.body).fetch("data").fetch("newerBuildLabel") assert_includes response.body, "Failed to build" @@ -348,17 +375,40 @@ class GalleryTest < ActionDispatch::IntegrationTest JSON.parse(response.body).fetch("data").fetch("newerBuildLabel") end - test "reviews an explicitly selected older successful Build" do + test "reloads the default view on the latest successful Build while explicit links stay pinned" do repository, prototype = sign_in_and_create_prototype selected = build_with_state(prototype, 1, "successful", "a", Time.current) - latest = build_with_state(prototype, 2, "successful", "b", Time.current) + default_path = repository_prototype_path(repository.github_id, prototype) + pinned_path = repository_prototype_path(repository.github_id, prototype, build: selected.number) - get repository_prototype_path(repository.github_id, prototype, build: selected.number), as: :json + get default_path, as: :json + assert_response :success + data = JSON.parse(response.body).fetch("data") + assert_equal selected.number, data.fetch("selectedBuild").fetch("number") + assert_equal false, data.fetch("buildPinned") + get pinned_path, as: :json + assert_response :success + assert_equal true, JSON.parse(response.body).fetch("data").fetch("buildPinned") + + latest = build_with_state(prototype, 2, "successful", "b", 1.minute.from_now) + + get default_path, as: :json + assert_response :success + data = JSON.parse(response.body).fetch("data") + assert_equal latest.number, data.fetch("selectedBuild").fetch("number") + assert_equal false, data.fetch("buildPinned") + assert_includes data.fetch("selectedBuild").fetch("previewUrl"), PreviewOrigin.current.url(latest.id) + + get pinned_path, as: :json assert_response :success assert_includes response.body, PreviewOrigin.current.url(selected.id) assert_not_includes response.body, PreviewOrigin.current.url(latest.id) - assert_not JSON.parse(response.body).fetch("data").key?("newerBuildLabel") + data = JSON.parse(response.body).fetch("data") + assert_equal selected.number, data.fetch("selectedBuild").fetch("number") + assert_equal true, data.fetch("buildPinned") + assert_equal default_path, data.fetch("branchPath") + assert_not data.key?("newerBuildLabel") end test "includes localized submit shortcuts for comments and replies" do @@ -401,13 +451,19 @@ class GalleryTest < ActionDispatch::IntegrationTest build = build_with_state(prototype, 1, "successful", "a", Time.current) build.commit_author.record!(commit_payload(build.commit_sha)) - get repository_prototype_path(repository.github_id, prototype) + get repository_prototype_path(repository.github_id, prototype), as: :json assert_response :success - assert_includes response.body, I18n.t("prototypes.show.share_links") - assert_includes \ - response.body, - repository_prototype_share_links_path(repository.github_id, prototype, build: build.number) + data = JSON.parse(response.body).fetch("data") + assert_equal \ + repository_prototype_share_links_path(repository.github_id, prototype), + data.fetch("shareLinksPath") + + get repository_prototype_path(repository.github_id, prototype, build: build.number), as: :json + assert_response :success + assert_equal \ + repository_prototype_share_links_path(repository.github_id, prototype, build: build.number), + JSON.parse(response.body).fetch("data").fetch("shareLinksPath") end test "offers detail editing only to a prototype author with write access" do @@ -500,9 +556,15 @@ class GalleryTest < ActionDispatch::IntegrationTest end selected = prototype.builds.find_by!(number: 1) - get repository_prototype_path(repository.github_id, prototype, build: selected.number) + get repository_prototype_path(repository.github_id, prototype, build: selected.number), as: :json assert_response :success + data = JSON.parse(response.body).fetch("data") + data.fetch("buildOptions").each do |option| + assert_equal \ + repository_prototype_path(repository.github_id, prototype, build: option.fetch("number")), + option.fetch("path") + end assert_includes response.body, PreviewOrigin.current.url(selected.id) assert_includes response.body, '"number":1' assert_includes response.body, '"number":2,' @@ -698,36 +760,34 @@ class GalleryTest < ActionDispatch::IntegrationTest test "paginates build history without shifting when a build is discovered" do repository, prototype = sign_in_and_create_prototype - 26.times do |index| + selected = build_with_state(prototype, 1, "successful", "a", Time.current) + 25.times do |index| builds.create \ prototype:, - number: index + 1, - commit_sha: index.to_s(16).rjust(40, "0") + number: index + 2, + commit_sha: (index + 1).to_s(16).rjust(40, "0") end - get repository_prototype_builds_path(repository.github_id, prototype), + get repository_prototype_builds_path(repository.github_id, prototype, build: selected.number), headers: { "Accept" => "application/json" } assert_response :success assert_includes response.body, "build 26" - assert_not_includes JSON.parse(response.body).fetch("data").fetch("builds").pluck("number"), 1 + first_page = JSON.parse(response.body).fetch("data") + assert_not_includes first_page.fetch("builds").pluck("number"), 1 assert_includes response.body, "Older builds" + next_page_path = first_page.fetch("nextPagePath") + assert_includes next_page_path, "build=#{selected.number}" builds.create(prototype:, number: 27, commit_sha: "f" * 40) - cursor = prototype.builds.find_by!(number: 2) - get \ - repository_prototype_builds_path( - repository.github_id, - prototype, - before: cursor.number, - before_at: cursor.chronological_at.iso8601(6) - ), - headers: { "Accept" => "application/json" } + get next_page_path, headers: { "Accept" => "application/json" } assert_response :success - assert_includes JSON.parse(response.body).fetch("data").fetch("builds").pluck("number"), 1 + next_page = JSON.parse(response.body).fetch("data") + assert_includes next_page.fetch("builds").pluck("number"), 1 assert_includes response.body, "Newest builds" - assert_not_includes JSON.parse(response.body).fetch("data").fetch("builds").pluck("number"), 27 + assert_includes next_page.fetch("newestPagePath"), "build=#{selected.number}" + assert_not_includes next_page.fetch("builds").pluck("number"), 27 end test "rejects malformed build-history cursors" do diff --git a/hub/test/integration/prototype_registrations_test.rb b/hub/test/integration/prototype_registrations_test.rb index 6c274fc4..39c502f3 100644 --- a/hub/test/integration/prototype_registrations_test.rb +++ b/hub/test/integration/prototype_registrations_test.rb @@ -308,6 +308,40 @@ class PrototypeRegistrationsTest < ActionDispatch::IntegrationTest assert_equal "A new direction", prototype.description end + test "keeps review edits unpinned after saving or a validation failure in the default view" do + payload = repository_payload(2001, "github/prototype") + stub_github_sign_in(repositories: [ payload ], permissions: { 2001 => "push" }) + sign_in + repository = Repository.find_by!(github_id: 2001) + prototype = prototypes.create(repository:, branch_name: "prototype/welcome") + prototype.register!(name: "Welcome", author: User.find_by!(login: "octocat")) + builds.create_successful(prototype:) + path = repository_prototype_registration_path(repository.github_id, prototype) + review_path = repository_prototype_path(repository.github_id, prototype) + + patch path, params: { + return_to: "review", + prototype: { name: "Updated", start_page: "/settings?tab=profile#details" } + } + assert_redirected_to review_path + assert_equal "Updated", prototype.reload.name + assert_equal "/settings?tab=profile#details", prototype.start_page + follow_redirect! + assert_response :success + assert_includes response.body, '"buildPinned":false' + + patch path, params: { + return_to: "review", + prototype: { start_page: "https://example.com/elsewhere" } + } + assert_redirected_to review_path + follow_redirect! + assert_response :success + assert_includes response.body, "must be a route" + assert_includes response.body, '"buildPinned":false' + assert_equal "/settings?tab=profile#details", prototype.reload.start_page + end + test "returns an edit from the review dialog to its selected successful Build" do payload = repository_payload(2001, "github/prototype") stub_github_sign_in(repositories: [ payload ], permissions: { 2001 => "push" }) diff --git a/hub/test/integration/share_links_test.rb b/hub/test/integration/share_links_test.rb index b314786b..b42507fd 100644 --- a/hub/test/integration/share_links_test.rb +++ b/hub/test/integration/share_links_test.rb @@ -11,12 +11,12 @@ class ShareLinksTest < ActionDispatch::IntegrationTest } link = ShareLink.find_by!(label: "Configured") assert_redirected_to repository_prototype_share_links_path( - @repository.github_id, @prototype, build: @build.number, "rc.page.title" => '"Configured"' + @repository.github_id, @prototype, "rc.page.title" => '"Configured"' ) delete repository_prototype_share_link_path(@repository.github_id, @prototype, link), params: { control_query: control_query } assert_redirected_to repository_prototype_share_links_path( - @repository.github_id, @prototype, build: @build.number, "rc.page.title" => '"Configured"' + @repository.github_id, @prototype, "rc.page.title" => '"Configured"' ) end @@ -102,6 +102,7 @@ class ShareLinksTest < ActionDispatch::IntegrationTest post @path, params: { + management_build: @build.number, share_link: { build_number: @build.number, label: "Usability review", @@ -121,7 +122,8 @@ class ShareLinksTest < ActionDispatch::IntegrationTest follow_redirect! assert_includes response.body, shared_build_path(share_link.public_id) - delete repository_prototype_share_link_path(@repository.github_id, @prototype, share_link) + delete repository_prototype_share_link_path(@repository.github_id, @prototype, share_link), + params: { management_build: @build.number } assert_redirected_to repository_prototype_share_links_path \ @repository.github_id, @@ -169,6 +171,49 @@ class ShareLinksTest < ActionDispatch::IntegrationTest end end + test "fresh authorization returns to Controls with the same default or explicit Build selection" do + control_query = URI.encode_www_form("rc.page.title" => '"Configured"') + session = Session.order(:id).last + session.update!(fresh_authorized_at: FreshAuthorization::FRESHNESS.ago) + assert_not FreshAuthorization.fresh?(session) + + with_share_link_maximum_lifetime(nil) do + assert_no_difference "ShareLink.count" do + post @path, params: { + control_query:, + share_link: { build_number: @build.number, label: "Default proof", expiration: "never" } + } + end + default_challenge = FreshAuthorization.order(:id).last + assert_redirected_to fresh_authorization_path(default_challenge.public_id) + assert_equal \ + repository_prototype_share_links_path( + @repository.github_id, + @prototype, + "rc.page.title" => '"Configured"' + ), + default_challenge.return_to + + assert_no_difference "ShareLink.count" do + post @path, params: { + control_query:, + management_build: @build.number, + share_link: { build_number: @build.number, label: "Pinned proof", expiration: "never" } + } + end + pinned_challenge = FreshAuthorization.order(:id).last + assert_redirected_to fresh_authorization_path(pinned_challenge.public_id) + assert_equal \ + repository_prototype_share_links_path( + @repository.github_id, + @prototype, + build: @build.number, + "rc.page.title" => '"Configured"' + ), + pinned_challenge.return_to + end + end + test "lets an author of an older successful Build share the selected Build" do newer = builds.create_successful \ prototype: @prototype, @@ -226,6 +271,16 @@ class ShareLinksTest < ActionDispatch::IntegrationTest number: 2, commit_sha: "b" * 40 + get @path, headers: { "Accept" => "application/json" } + + assert_response :success + data = JSON.parse(response.body).fetch("data") + assert_equal newer.number, data.fetch("selectedBuild").fetch("number") + assert_equal repository_prototype_path(@repository.github_id, @prototype), data.fetch("branchPath") + assert_equal data.fetch("branchPath"), data.fetch("closePath") + assert_equal @path, data.fetch("shareLinksPath") + assert_not data.key?("managementBuildNumber") + get @path, params: { build: older.number }, headers: { "Accept" => "application/json" } assert_response :success @@ -236,10 +291,14 @@ class ShareLinksTest < ActionDispatch::IntegrationTest assert_equal older.number, data.fetch("selectedBuild").fetch("number") assert_equal I18n.t("share_links.index.build", number: older.number), data.fetch("form").fetch("buildLabel") assert_equal newer.number, data.fetch("latestBuild").fetch("number") - assert_equal repository_prototype_path(@repository.github_id, @prototype, build: older.number), + assert_equal \ + repository_prototype_path(@repository.github_id, @prototype), data.fetch("branchPath") - assert_equal data.fetch("branchPath"), data.fetch("closePath") + assert_equal \ + repository_prototype_path(@repository.github_id, @prototype, build: older.number), + data.fetch("closePath") assert_equal @path + "?build=#{older.number}", data.fetch("shareLinksPath") + assert_equal older.number, data.fetch("managementBuildNumber") assert_equal %w[detailsLabel detailsPath number], data.fetch("selectedBuild").keys.sort assert_not data.key?("reviewComments") end @@ -302,6 +361,31 @@ class ShareLinksTest < ActionDispatch::IntegrationTest assert_response :not_found end + test "rejects invalid management Builds before creating or deleting links" do + queued = builds.create \ + prototype: @prototype, + number: 2, + commit_sha: "b" * 40 + link = create_share_link(label: "Retained") + + [ "0", "999", queued.number ].each do |management_build| + assert_no_difference "ShareLink.count" do + post @path, params: { + management_build:, + share_link: { build_number: @build.number, label: "Rejected #{management_build}" } + } + end + assert_response :not_found + + assert_no_difference "ShareLink.count" do + delete repository_prototype_share_link_path(@repository.github_id, @prototype, link), + params: { management_build: } + end + assert_response :not_found + assert ShareLink.exists?(link.id) + end + end + test "keeps validation failures on the browser management page" do [ { expiration: "invalid" }, { label: "" } ].each do |attributes| assert_no_difference "ShareLink.count" do @@ -310,12 +394,58 @@ class ShareLinksTest < ActionDispatch::IntegrationTest end assert_redirected_to repository_prototype_share_links_path \ @repository.github_id, - @prototype, - build: @build.number + @prototype assert flash[:alert].present? end end + test "keeps default mutations unpinned and explicit management selections pinned" do + newer = builds.create_successful \ + prototype: @prototype, + number: 2, + commit_sha: "b" * 40 + + post @path, params: { + share_link: { build_number: @build.number, label: "Default management" } + } + default_link = ShareLink.find_by!(label: "Default management") + assert_redirected_to @path + follow_redirect!(headers: { "Accept" => "application/json" }) + default_data = JSON.parse(response.body).fetch("data") + assert_equal newer.number, default_data.fetch("selectedBuild").fetch("number") + assert_equal repository_prototype_path(@repository.github_id, @prototype), + default_data.fetch("closePath") + + delete repository_prototype_share_link_path(@repository.github_id, @prototype, default_link) + assert_redirected_to @path + follow_redirect!(headers: { "Accept" => "application/json" }) + assert_equal repository_prototype_path(@repository.github_id, @prototype), + JSON.parse(response.body).fetch("data").fetch("closePath") + + post @path, params: { + management_build: @build.number, + share_link: { build_number: newer.number, label: "Pinned management" } + } + pinned_link = ShareLink.find_by!(label: "Pinned management") + pinned_path = repository_prototype_share_links_path \ + @repository.github_id, + @prototype, + build: @build.number + assert_redirected_to pinned_path + follow_redirect!(headers: { "Accept" => "application/json" }) + pinned_data = JSON.parse(response.body).fetch("data") + assert_equal @build.number, pinned_data.fetch("selectedBuild").fetch("number") + assert_equal repository_prototype_path(@repository.github_id, @prototype, build: @build.number), + pinned_data.fetch("closePath") + + delete repository_prototype_share_link_path(@repository.github_id, @prototype, pinned_link), + params: { management_build: @build.number } + assert_redirected_to pinned_path + follow_redirect!(headers: { "Accept" => "application/json" }) + assert_equal repository_prototype_path(@repository.github_id, @prototype, build: @build.number), + JSON.parse(response.body).fetch("data").fetch("closePath") + end + private def create_share_link(label:, expiration: "seven_days")