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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .server-changes/runs-bulk-action-no-reload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: fix
---

Stop reloading the runs list when opening or closing the bulk action inspector
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { BeakerIcon, BookOpenIcon } from "@heroicons/react/24/solid";
import { type MetaFunction, useNavigation, useRevalidator } from "@remix-run/react";
import { type MetaFunction, useLocation, useNavigation, useRevalidator } from "@remix-run/react";
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { Suspense } from "react";
import { Suspense, useState } from "react";
import {
TypedAwait,
typeddefer,
Expand Down Expand Up @@ -56,7 +56,6 @@ import { cn } from "~/utils/cn";
import {
docsPath,
EnvironmentParamSchema,
v3CreateBulkActionPath,
v3ProjectPath,
v3TestPath,
v3TestTaskPath,
Expand All @@ -65,8 +64,11 @@ import { throwNotFound } from "~/utils/httpErrors";
import { ListPagination } from "../../components/ListPagination";
import { CreateBulkActionInspector } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.bulkaction";
import { Callout } from "~/components/primitives/Callout";
import { isRunsListLoading, RUNS_BULK_INSPECTOR_OPEN_VALUE, shouldRevalidateRunsList } from "./shouldRevalidateRunsList";
import { useRunsLiveReload } from "./useRunsLiveReload";

export { shouldRevalidateRunsList as shouldRevalidate };

export const meta: MetaFunction = () => {
return [
{
Expand Down Expand Up @@ -209,8 +211,9 @@ function RunsList({
filters: TaskRunListSearchFilters;
}) {
const revalidator = useRevalidator();
const location = useLocation();
const navigation = useNavigation();
const isLoading = navigation.state !== "idle";
const isLoading = isRunsListLoading(navigation, location.search);
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
Expand Down Expand Up @@ -244,7 +247,7 @@ function RunsList({
shortcut: { key: "r" },
action: (e) => {
replace({
bulkInspector: "true",
bulkInspector: RUNS_BULK_INSPECTOR_OPEN_VALUE,
action: "replay",
mode: selectedItems.size > 0 ? "selected" : undefined,
});
Expand All @@ -254,14 +257,21 @@ function RunsList({
shortcut: { key: "c" },
action: (e) => {
replace({
bulkInspector: "true",
bulkInspector: RUNS_BULK_INSPECTOR_OPEN_VALUE,
action: "cancel",
mode: selectedItems.size > 0 ? "selected" : undefined,
});
},
});

const isShowingBulkActionInspector = has("bulkInspector") && list.hasAnyRuns;
const [isBulkInspectorPanelCollapsed, setIsBulkInspectorPanelCollapsed] = useState(
!isShowingBulkActionInspector
);
// Keep content mounted until onCollapseChange reports the panel is fully collapsed.
const showBulkInspectorContent =
isShowingBulkActionInspector || !isBulkInspectorPanelCollapsed;

return (
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
<ResizablePanel id="runs-main" min={"100px"}>
Expand Down Expand Up @@ -310,39 +320,41 @@ function RunsList({
</Button>
</span>
)}
{!isShowingBulkActionInspector && (
<LinkButton
variant="secondary/small"
to={v3CreateBulkActionPath(
organization,
project,
environment,
filters,
selectedItems.size > 0 ? "selected" : undefined
)}
LeadingIcon={ListCheckedIcon}
className={selectedItems.size > 0 ? "pr-1" : undefined}
tooltip={
<div className="-mr-1 flex items-center gap-3 text-xs text-text-dimmed">
<div className="flex items-center gap-0.5">
<span>Replay</span>
<ShortcutKey shortcut={{ key: "r" }} variant={"small"} />
</div>
<div className="flex items-center gap-0.5">
<span>Cancel</span>
<ShortcutKey shortcut={{ key: "c" }} variant={"small"} />
</div>
{/* Stay mounted while the inspector is open to avoid toolbar layout shift. */}
<Button
variant="secondary/small"
disabled={isShowingBulkActionInspector}
onClick={() =>
replace({
bulkInspector: RUNS_BULK_INSPECTOR_OPEN_VALUE,
mode: selectedItems.size > 0 ? "selected" : undefined,
})
}
LeadingIcon={ListCheckedIcon}
className={cn(
selectedItems.size > 0 ? "pr-1" : undefined,
isShowingBulkActionInspector && "pointer-events-none invisible"
Comment thread
kathiekiwi marked this conversation as resolved.
)}
tooltip={
<div className="-mr-1 flex items-center gap-3 text-xs text-text-dimmed">
<div className="flex items-center gap-0.5">
<span>Replay</span>
<ShortcutKey shortcut={{ key: "r" }} variant={"small"} />
</div>
}
>
<span className="flex items-center gap-x-1 whitespace-nowrap text-text-bright">
<span>Bulk action</span>
{selectedItems.size > 0 && (
<Badge variant="rounded">{selectedItems.size}</Badge>
)}
</span>
</LinkButton>
)}
<div className="flex items-center gap-0.5">
<span>Cancel</span>
<ShortcutKey shortcut={{ key: "c" }} variant={"small"} />
</div>
</div>
}
>
<span className="flex items-center gap-x-1 whitespace-nowrap text-text-bright">
<span>Bulk action</span>
{selectedItems.size > 0 && (
<Badge variant="rounded">{selectedItems.size}</Badge>
)}
</span>
</Button>
<ListPagination list={list} />
</div>
</div>
Expand Down Expand Up @@ -374,12 +386,12 @@ function RunsList({
className="overflow-hidden"
collapsible
collapsed={!isShowingBulkActionInspector}
onCollapseChange={() => {}}
onCollapseChange={setIsBulkInspectorPanelCollapsed}
collapsedSize="0px"
collapseAnimation={RESIZABLE_PANEL_ANIMATION}
>
<div className="h-full" style={{ minWidth: 400 }}>
{isShowingBulkActionInspector && (
{showBulkInspectorContent && (
<CreateBulkActionInspector
filters={filters}
selectedItems={selectedItems}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import type { Navigation, ShouldRevalidateFunction } from "@remix-run/react";

/** Search params that only control the bulk-action inspector UI, not list data. */
export const RUNS_BULK_INSPECTOR_UI_SEARCH_PARAMS = ["bulkInspector", "action", "mode"] as const;

/** URL value set on `bulkInspector` when the inspector panel is open (presence flag). */
export const RUNS_BULK_INSPECTOR_OPEN_VALUE = "show";

/** Returns a copy with bulk-inspector UI params removed. */
export function stripBulkInspectorUiParams(params: URLSearchParams): URLSearchParams {
const stripped = new URLSearchParams(params);
for (const key of RUNS_BULK_INSPECTOR_UI_SEARCH_PARAMS) {
stripped.delete(key);
}
return stripped;
}

/** Canonical string for list-data params (UI keys stripped, entries sorted by key). */
export function canonicalRunsListDataSearchParams(params: URLSearchParams): string {
const stripped = stripBulkInspectorUiParams(params);
stripped.sort();
return stripped.toString();
}

export function searchParamsEqualIgnoringBulkInspectorUiState(
current: URLSearchParams,
next: URLSearchParams
) {
return (
canonicalRunsListDataSearchParams(current) === canonicalRunsListDataSearchParams(next)
);
}

/** True when navigation should show the runs table loading state (excludes bulk-inspector UI toggles). */
export function isRunsListLoading(navigation: Navigation, currentSearch: string): boolean {
if (navigation.state === "idle" || !navigation.location) {
return false;
}

const currentParams = new URLSearchParams(currentSearch);
const nextParams = new URLSearchParams(navigation.location.search);

if (searchParamsEqualIgnoringBulkInspectorUiState(currentParams, nextParams)) {
return false;
}

return true;
}

/**
* Skip runs list loader revalidation when only bulk-inspector UI params change.
* Explicit revalidate() (unchanged URL) and filter/pagination changes still revalidate.
*/
export const shouldRevalidateRunsList: ShouldRevalidateFunction = ({
currentUrl,
nextUrl,
defaultShouldRevalidate,
}) => {
if (currentUrl.pathname !== nextUrl.pathname) {
return defaultShouldRevalidate;
}

const currentParams = new URLSearchParams(currentUrl.search);
const nextParams = new URLSearchParams(nextUrl.search);

if (currentParams.toString() === nextParams.toString()) {
return defaultShouldRevalidate;
}

if (searchParamsEqualIgnoringBulkInspectorUiState(currentParams, nextParams)) {
return false;
}

return defaultShouldRevalidate;
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { useTypedFetcher } from "remix-typedjson";
import { useInterval } from "~/hooks/useInterval";
import type { NextRunListItem } from "~/presenters/v3/NextRunListPresenter.server";
import type { loader as liveRunsLoader } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.live";
import { stripBulkInspectorUiParams } from "./shouldRevalidateRunsList";

const RUNS_SEARCH_PARAMS_TO_REMOVE = ["cursor", "direction", "bulkInspector", "action", "mode"];
const RUNS_PAGINATION_SEARCH_PARAMS = ["cursor", "direction"] as const;
const RUNS_POLL_INTERVAL_MS = 3000;
/** Check for new runs every N poll ticks (~6s at 3s interval). */
const NEW_RUNS_EVERY_N_POLL_TICKS = 2;
Expand All @@ -30,8 +31,8 @@ function maxCreatedAtMs(runs: ListedRun[]): number | undefined {
}

function filterParamsWithoutPagination(search: string) {
const params = new URLSearchParams(search);
for (const key of RUNS_SEARCH_PARAMS_TO_REMOVE) {
const params = stripBulkInspectorUiParams(new URLSearchParams(search));
for (const key of RUNS_PAGINATION_SEARCH_PARAMS) {
params.delete(key);
}
return params;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
AccordionItem,
AccordionTrigger,
} from "~/components/primitives/Accordion";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Button } from "~/components/primitives/Buttons";
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
import {
Dialog,
Expand Down Expand Up @@ -51,10 +51,11 @@ import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/m
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { CreateBulkActionPresenter } from "~/presenters/v3/CreateBulkActionPresenter.server";
import { RUNS_BULK_INSPECTOR_UI_SEARCH_PARAMS } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/shouldRevalidateRunsList";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { cn } from "~/utils/cn";
import { EnvironmentParamSchema, v3BulkActionPath, v3RunsPath } from "~/utils/pathBuilder";
import { EnvironmentParamSchema, v3BulkActionPath } from "~/utils/pathBuilder";
import { BulkActionService } from "~/v3/services/bulk/BulkActionV2.server";

export async function loader({ request, params }: LoaderFunctionArgs) {
Expand Down Expand Up @@ -187,7 +188,7 @@ export function CreateBulkActionInspector({
const project = useProject();
const environment = useEnvironment();
const fetcher = useTypedFetcher<typeof loader>();
const { value, replace } = useSearchParams();
const { value, replace, del } = useSearchParams();
const [action, setAction] = useState<BulkActionAction>(
bulkActionActionFromString(value("action"))
);
Expand All @@ -208,9 +209,6 @@ export function CreateBulkActionInspector({

const data = fetcher.data != null ? fetcher.data : undefined;

const closedSearchParams = new URLSearchParams(location.search);
closedSearchParams.delete("bulkInspector");

const impactedCountElement =
mode === "selected" ? selectedItems.size : <EstimatedCount count={data?.count} />;

Expand All @@ -225,13 +223,10 @@ export function CreateBulkActionInspector({
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr_3.25rem] overflow-hidden bg-background-bright">
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
<Header2 className="whitespace-nowrap">Create a bulk action</Header2>
<LinkButton
to={`${v3RunsPath(
organization,
project,
environment
)}?${closedSearchParams.toString()}`}
<Button
type="button"
variant="minimal/small"
onClick={() => del([...RUNS_BULK_INSPECTOR_UI_SEARCH_PARAMS])}
TrailingIcon={ExitIcon}
shortcut={{ key: "esc" }}
shortcutPosition="before-trailing-icon"
Expand Down
3 changes: 2 additions & 1 deletion apps/webapp/app/utils/pathBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { z } from "zod";
import { type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
import type { Organization } from "~/models/organization.server";
import type { Project } from "~/models/project.server";
import { RUNS_BULK_INSPECTOR_OPEN_VALUE } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/shouldRevalidateRunsList";
import { objectToSearchParams } from "./searchParams";
import { type WaitpointSearchParams } from "~/components/runs/v3/WaitpointTokenFilters";
export type OrgForPath = Pick<Organization, "slug">;
Expand Down Expand Up @@ -363,7 +364,7 @@ export function v3CreateBulkActionPath(
action?: "replay" | "cancel"
) {
const searchParams = objectToSearchParams(filters) ?? new URLSearchParams();
searchParams.set("bulkInspector", "show");
searchParams.set("bulkInspector", RUNS_BULK_INSPECTOR_OPEN_VALUE);
if (mode) {
searchParams.set("mode", mode);
}
Expand Down
Loading