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")