|
1 | 1 | import { BookOpenIcon, PlusIcon } from "@heroicons/react/20/solid"; |
2 | | -import { useFetcher, useRevalidator, type MetaFunction } from "@remix-run/react"; |
| 2 | +import { |
| 3 | + useFetcher, |
| 4 | + useLocation, |
| 5 | + useNavigation, |
| 6 | + useRevalidator, |
| 7 | + type MetaFunction, |
| 8 | +} from "@remix-run/react"; |
3 | 9 | import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; |
4 | | -import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; |
5 | | -import { TypedAwait, typeddefer, useTypedFetcher, useTypedLoaderData } from "remix-typedjson"; |
| 10 | +import { |
| 11 | + type MutableRefObject, |
| 12 | + Suspense, |
| 13 | + useCallback, |
| 14 | + useEffect, |
| 15 | + useMemo, |
| 16 | + useRef, |
| 17 | + useState, |
| 18 | +} from "react"; |
| 19 | +import { |
| 20 | + TypedAwait, |
| 21 | + typeddefer, |
| 22 | + type UseDataFunctionReturn, |
| 23 | + useTypedFetcher, |
| 24 | + useTypedLoaderData, |
| 25 | +} from "remix-typedjson"; |
6 | 26 | import { z } from "zod"; |
7 | 27 | import { BeakerIcon } from "~/assets/icons/BeakerIcon"; |
8 | 28 | import { ClockIcon } from "~/assets/icons/ClockIcon"; |
@@ -32,6 +52,7 @@ import { InfoPanel } from "~/components/primitives/InfoPanel"; |
32 | 52 | import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; |
33 | 53 | import { PaginationControls } from "~/components/primitives/Pagination"; |
34 | 54 | import { Paragraph } from "~/components/primitives/Paragraph"; |
| 55 | +import { PulsingDot } from "~/components/primitives/PulsingDot"; |
35 | 56 | import * as Property from "~/components/primitives/PropertyTable"; |
36 | 57 | import { |
37 | 58 | ResizableHandle, |
@@ -95,6 +116,7 @@ import type { loader as scheduleEditLoader } from "../_app.orgs.$organizationSlu |
95 | 116 | import type { loader as scheduleNewLoader } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route"; |
96 | 117 | import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; |
97 | 118 | import { UpsertScheduleForm } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.new/route"; |
| 119 | +import { useRunsLiveReload } from "../_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload"; |
98 | 120 |
|
99 | 121 | export const meta: MetaFunction<typeof loader> = ({ data }) => { |
100 | 122 | const slug = (data as { task?: TaskDetail | null } | undefined)?.task?.slug; |
@@ -181,6 +203,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { |
181 | 203 | to, |
182 | 204 | cursor, |
183 | 205 | direction, |
| 206 | + includeHasAnyRuns: true, |
184 | 207 | }) |
185 | 208 | .catch(() => null); |
186 | 209 |
|
@@ -231,6 +254,13 @@ export default function Page() { |
231 | 254 | !!plan?.v3Subscription?.plan && !plan.v3Subscription.plan.limits.schedules.canExceed; |
232 | 255 | const isAtLimit = !!limits && limits.used >= limits.limit; |
233 | 256 |
|
| 257 | + // New-runs banner state is lifted here so the button can live in the top bar, |
| 258 | + // while the count/action originate from the live-reload hook inside the |
| 259 | + // deferred runs table below. Count drives visibility; the ref exposes the |
| 260 | + // click action (kept current by TaskRunsList each render). |
| 261 | + const [newRunsCount, setNewRunsCount] = useState(0); |
| 262 | + const showNewRunsRef = useRef<() => void>(() => {}); |
| 263 | + |
234 | 264 | return ( |
235 | 265 | <PageContainer> |
236 | 266 | <NavBar> |
@@ -265,6 +295,9 @@ export default function Page() { |
265 | 295 | onCreate={openCreateSchedule} |
266 | 296 | disabled={isCreatingSchedule} |
267 | 297 | /> |
| 298 | + {newRunsCount > 0 ? ( |
| 299 | + <NewRunsButton count={newRunsCount} onClick={() => showNewRunsRef.current()} /> |
| 300 | + ) : null} |
268 | 301 | <TimeFilter defaultPeriod="7d" labelName="Runs" /> |
269 | 302 | <LinkButton |
270 | 303 | variant="secondary/small" |
@@ -321,17 +354,12 @@ export default function Page() { |
321 | 354 | <TypedAwait resolve={runList} errorElement={<TableLoading />}> |
322 | 355 | {(list) => |
323 | 356 | list ? ( |
324 | | - <div className="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"> |
325 | | - <TaskRunsTable |
326 | | - total={list.runs.length} |
327 | | - hasFilters={list.hasFilters} |
328 | | - filters={list.filters} |
329 | | - runs={list.runs} |
330 | | - variant="dimmed" |
331 | | - showTopBorder={false} |
332 | | - stickyHeader |
333 | | - /> |
334 | | - </div> |
| 357 | + <TaskRunsList |
| 358 | + list={list} |
| 359 | + taskSlug={task.slug} |
| 360 | + onNewRunsCountChange={setNewRunsCount} |
| 361 | + showNewRunsRef={showNewRunsRef} |
| 362 | + /> |
335 | 363 | ) : ( |
336 | 364 | <TableLoading /> |
337 | 365 | ) |
@@ -397,6 +425,111 @@ export default function Page() { |
397 | 425 | ); |
398 | 426 | } |
399 | 427 |
|
| 428 | +type TaskRunList = NonNullable<Awaited<UseDataFunctionReturn<typeof loader>["runList"]>>; |
| 429 | + |
| 430 | +/** |
| 431 | + * Compact "N new runs" button, shown in the page top bar to the left of the |
| 432 | + * time filter when the live-reload hook has detected newer runs. |
| 433 | + */ |
| 434 | +function NewRunsButton({ count, onClick }: { count: number; onClick: () => void }) { |
| 435 | + return ( |
| 436 | + <span className="flex duration-150 animate-in fade-in-0"> |
| 437 | + <Button |
| 438 | + variant="secondary/small" |
| 439 | + className="text-text-bright" |
| 440 | + onClick={onClick} |
| 441 | + LeadingIcon={<PulsingDot className="h-2 w-2" />} |
| 442 | + tooltip="Refresh to see new runs" |
| 443 | + aria-label="New runs created. Refresh to see new runs." |
| 444 | + > |
| 445 | + {count >= 100 ? "99+ new runs" : `${count} new ${count === 1 ? "run" : "runs"}`} |
| 446 | + </Button> |
| 447 | + </span> |
| 448 | + ); |
| 449 | +} |
| 450 | + |
| 451 | +/** |
| 452 | + * Runs table with live updating. Mirrors the Runs list page: active rows are |
| 453 | + * patched in place (status/timing/cost). The "N new runs" count is surfaced to |
| 454 | + * the top-bar button via `onNewRunsCountChange` (count → visibility) and |
| 455 | + * `showNewRunsRef` (the latest click action), since the button lives outside |
| 456 | + * this deferred boundary. The task lives in the route path rather than a |
| 457 | + * `tasks` filter, so we pass `taskSlug` to scope new-run detection to this task. |
| 458 | + */ |
| 459 | +function TaskRunsList({ |
| 460 | + list, |
| 461 | + taskSlug, |
| 462 | + onNewRunsCountChange, |
| 463 | + showNewRunsRef, |
| 464 | +}: { |
| 465 | + list: TaskRunList; |
| 466 | + taskSlug: string; |
| 467 | + onNewRunsCountChange: (count: number) => void; |
| 468 | + showNewRunsRef: MutableRefObject<() => void>; |
| 469 | +}) { |
| 470 | + const organization = useOrganization(); |
| 471 | + const project = useProject(); |
| 472 | + const environment = useEnvironment(); |
| 473 | + const navigation = useNavigation(); |
| 474 | + const location = useLocation(); |
| 475 | + const { has, replace } = useSearchParams(); |
| 476 | + const revalidator = useRevalidator(); |
| 477 | + |
| 478 | + // Loading a new version of this same page (time filter / pagination change). |
| 479 | + const isLoading = |
| 480 | + navigation.state === "loading" && |
| 481 | + navigation.location !== undefined && |
| 482 | + navigation.location.pathname === location.pathname && |
| 483 | + navigation.location.search !== location.search; |
| 484 | + |
| 485 | + const { visibleRuns, newRunsCount, dismissNewRuns, childrenStatusesBasePath } = useRunsLiveReload({ |
| 486 | + runs: list.runs, |
| 487 | + hasAnyRuns: list.hasAnyRuns, |
| 488 | + isLoading, |
| 489 | + organizationSlug: organization.slug, |
| 490 | + projectSlug: project.slug, |
| 491 | + environmentSlug: environment.slug, |
| 492 | + taskSlug, |
| 493 | + }); |
| 494 | + |
| 495 | + const onClickShowNewRuns = () => { |
| 496 | + const isPaginated = has("cursor") || has("direction"); |
| 497 | + dismissNewRuns(); |
| 498 | + if (isPaginated) { |
| 499 | + replace({ cursor: undefined, direction: undefined }); |
| 500 | + return; |
| 501 | + } |
| 502 | + revalidator.revalidate(); |
| 503 | + }; |
| 504 | + |
| 505 | + // Surface the banner to the top-bar button rendered by Page: keep the ref's |
| 506 | + // action current, mirror the count up, and clear it when this boundary |
| 507 | + // unmounts (e.g. the table re-suspends on a filter change). |
| 508 | + useEffect(() => { |
| 509 | + showNewRunsRef.current = onClickShowNewRuns; |
| 510 | + }, [onClickShowNewRuns, showNewRunsRef]); |
| 511 | + useEffect(() => { |
| 512 | + onNewRunsCountChange(newRunsCount); |
| 513 | + }, [newRunsCount, onNewRunsCountChange]); |
| 514 | + useEffect(() => () => onNewRunsCountChange(0), [onNewRunsCountChange]); |
| 515 | + |
| 516 | + return ( |
| 517 | + <div className="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"> |
| 518 | + <TaskRunsTable |
| 519 | + total={visibleRuns.length} |
| 520 | + hasFilters={list.hasFilters} |
| 521 | + filters={list.filters} |
| 522 | + runs={visibleRuns} |
| 523 | + childrenStatusesBasePath={childrenStatusesBasePath} |
| 524 | + isLoading={isLoading} |
| 525 | + variant="dimmed" |
| 526 | + showTopBorder={false} |
| 527 | + stickyHeader |
| 528 | + /> |
| 529 | + </div> |
| 530 | + ); |
| 531 | +} |
| 532 | + |
400 | 533 | /** |
401 | 534 | * "Create schedule" button with a limit-exceeded intercept. When the project |
402 | 535 | * is already at its schedules limit, clicking opens a dialog explaining the |
|
0 commit comments