Skip to content

Commit cf0fdde

Browse files
authored
Feat(webapp): animated resizable panel (#3319)
This is a small improvement mainly with the UI Skills file: - Animate open and close the Resizable panels - Uses the built in animation hooks from react-window-splitter - Includes a global variable for the animation easing and timing for consistency https://github.com/user-attachments/assets/50ed0019-ed12-4e08-b95c-7c6d1fe5bac0
1 parent e31b03e commit cf0fdde

File tree

19 files changed

+597
-169
lines changed
  • apps/webapp/app
    • components
    • routes
      • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index
      • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches.$batchParam
      • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.batches
      • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.bulk-actions
      • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments
      • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs
      • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.models._index
      • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.regions
      • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam
      • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index
      • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules
      • _app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.waitpoints.tokens
      • storybook.animated-panel
      • storybook

19 files changed

+597
-169
lines changed

apps/webapp/app/components/GitMetadata.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ export function GitMetadataBranch({
2525
<LinkButton
2626
variant="minimal/small"
2727
LeadingIcon={<GitBranchIcon className="size-4" />}
28+
leadingIconClassName="group-hover/table-row:text-text-bright"
2829
iconSpacing="gap-x-1"
2930
to={git.branchUrl}
30-
className="pl-1"
31+
className="pl-1 duration-0 [&_span]:duration-0 [&_span]:group-hover/table-row:text-text-bright"
3132
>
3233
{git.branchName}
3334
</LinkButton>
@@ -49,8 +50,9 @@ export function GitMetadataCommit({
4950
variant="minimal/small"
5051
to={git.commitUrl}
5152
LeadingIcon={<GitCommitIcon className="size-4" />}
53+
leadingIconClassName="group-hover/table-row:text-text-bright"
5254
iconSpacing="gap-x-1"
53-
className="pl-1"
55+
className="pl-1 duration-0 [&_span]:duration-0 [&_span]:group-hover/table-row:text-text-bright"
5456
>
5557
{`${git.shortSha} / ${git.commitMessage}`}
5658
</LinkButton>
@@ -74,8 +76,9 @@ export function GitMetadataPullRequest({
7476
variant="minimal/small"
7577
to={git.pullRequestUrl}
7678
LeadingIcon={<GitPullRequestIcon className="size-4" />}
79+
leadingIconClassName="group-hover/table-row:text-text-bright"
7780
iconSpacing="gap-x-1"
78-
className="pl-1"
81+
className="pl-1 duration-0 [&_span]:duration-0 [&_span]:group-hover/table-row:text-text-bright"
7982
>
8083
#{git.pullRequestNumber} {git.pullRequestTitle}
8184
</LinkButton>

apps/webapp/app/components/logs/LogsTable.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,8 @@ export function LogsTable({
167167
>
168168
<DateTimeAccurate date={log.triggeredTimestamp} hour12={false} />
169169
</TableCell>
170-
<TableCell className="min-w-24" onClick={handleRowClick} hasAction>
171-
<TruncatedCopyableValue value={log.runId} />
170+
<TableCell className="min-w-24 cursor-pointer" onClick={handleRowClick}>
171+
<span className="font-mono text-xs">{log.runId}</span>
172172
</TableCell>
173173
<TableCell className="min-w-32" onClick={handleRowClick} hasAction>
174174
<span className="font-mono text-xs">{log.taskIdentifier}</span>

apps/webapp/app/components/primitives/Resizable.tsx

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import React from "react";
3+
import React, { useRef } from "react";
44
import { PanelGroup, Panel, PanelResizer } from "react-window-splitter";
55
import { cn } from "~/utils/cn";
66

@@ -69,6 +69,30 @@ const ResizableHandle = ({
6969
</PanelResizer>
7070
);
7171

72-
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };
72+
const RESIZABLE_PANEL_ANIMATION = {
73+
easing: "ease-in-out" as const,
74+
duration: 200,
75+
};
76+
77+
const COLLAPSIBLE_HANDLE_CLASSNAME = "transition-opacity duration-200";
78+
79+
function collapsibleHandleClassName(show: boolean) {
80+
return cn(COLLAPSIBLE_HANDLE_CLASSNAME, !show && "pointer-events-none opacity-0");
81+
}
82+
83+
function useFrozenValue<T>(value: T | null | undefined): T | null | undefined {
84+
const ref = useRef(value);
85+
if (value != null) ref.current = value;
86+
return ref.current;
87+
}
88+
89+
export {
90+
RESIZABLE_PANEL_ANIMATION,
91+
ResizableHandle,
92+
ResizablePanel,
93+
ResizablePanelGroup,
94+
collapsibleHandleClassName,
95+
useFrozenValue,
96+
};
7397

7498
export type ResizableSnapshot = React.ComponentProps<typeof PanelGroup>["snapshot"];

apps/webapp/app/components/primitives/SearchInput.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ export type SearchInputProps = {
1010
placeholder?: string;
1111
/** Additional URL params to reset when searching or clearing (e.g. pagination). Defaults to ["cursor", "direction"]. */
1212
resetParams?: string[];
13+
autoFocus?: boolean;
1314
};
1415

1516
export function SearchInput({
1617
placeholder = "Search logs…",
1718
resetParams = ["cursor", "direction"],
19+
autoFocus,
1820
}: SearchInputProps) {
1921
const inputRef = useRef<HTMLInputElement>(null);
2022

@@ -71,6 +73,7 @@ export function SearchInput({
7173
value={text}
7274
onChange={(e) => setText(e.target.value)}
7375
fullWidth
76+
autoFocus={autoFocus}
7477
className={cn("", isFocused && "placeholder:text-text-dimmed/70")}
7578
onKeyDown={(e) => {
7679
if (e.key === "Enter") {

apps/webapp/app/components/primitives/TreeView/TreeView.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,18 @@ export function useTree<TData, TFilterValue>({
197197
concreteStateFromInput({ tree, selectedId, collapsedIds, filter })
198198
);
199199

200+
//sync external selectedId prop into internal state
201+
useEffect(() => {
202+
const internalSelectedId = selectedIdFromState(state.nodes);
203+
if (selectedId !== internalSelectedId) {
204+
if (selectedId === undefined) {
205+
dispatch({ type: "DESELECT_ALL_NODES" });
206+
} else {
207+
dispatch({ type: "SELECT_NODE", payload: { id: selectedId, scrollToNode: false, scrollToNodeFn } });
208+
}
209+
}
210+
}, [selectedId]);
211+
200212
//fire onSelectedIdChanged()
201213
useEffect(() => {
202214
const selectedId = selectedIdFromState(state.nodes);

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam._index/route.tsx

Lines changed: 101 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,18 @@ import {
66
ExclamationTriangleIcon,
77
LightBulbIcon,
88
MagnifyingGlassIcon,
9+
XMarkIcon,
910
UserPlusIcon,
1011
VideoCameraIcon,
1112
} from "@heroicons/react/20/solid";
1213
import { json, type MetaFunction } from "@remix-run/node";
13-
import { Link, useRevalidator, useSubmit } from "@remix-run/react";
14+
import { Link, useFetcher, useRevalidator } from "@remix-run/react";
1415
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
1516
import { DiscordIcon } from "@trigger.dev/companyicons";
1617
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
1718
import type { TaskRunStatus } from "@trigger.dev/database";
18-
import { Fragment, Suspense, useEffect, useState } from "react";
19+
import { Fragment, Suspense, useCallback, useEffect, useRef, useState } from "react";
20+
import type { PanelHandle } from "react-window-splitter";
1921
import { Bar, BarChart, ResponsiveContainer, Tooltip, type TooltipProps } from "recharts";
2022
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
2123
import { ExitIcon } from "~/assets/icons/ExitIcon";
@@ -42,9 +44,11 @@ import { Paragraph } from "~/components/primitives/Paragraph";
4244
import { PopoverMenuItem } from "~/components/primitives/Popover";
4345
import * as Property from "~/components/primitives/PropertyTable";
4446
import {
47+
RESIZABLE_PANEL_ANIMATION,
4548
ResizableHandle,
4649
ResizablePanel,
4750
ResizablePanelGroup,
51+
collapsibleHandleClassName,
4852
} from "~/components/primitives/Resizable";
4953
import { Spinner } from "~/components/primitives/Spinner";
5054
import { StepNumber } from "~/components/primitives/StepNumber";
@@ -84,6 +88,7 @@ import {
8488
uiPreferencesStorage,
8589
} from "~/services/preferences/uiPreferences.server";
8690
import { requireUserId } from "~/services/session.server";
91+
import { motion } from "framer-motion";
8792
import { cn } from "~/utils/cn";
8893
import {
8994
docsPath,
@@ -192,14 +197,20 @@ export default function Page() {
192197
}, [streamedEvents]); // eslint-disable-line react-hooks/exhaustive-deps
193198

194199
const [showUsefulLinks, setShowUsefulLinks] = useState(usefulLinksPreference ?? true);
200+
const usefulLinksPanelRef = useRef<PanelHandle>(null);
201+
const fetcher = useFetcher();
202+
const fetcherRef = useRef(fetcher);
203+
fetcherRef.current = fetcher;
195204

196-
// Create a submit handler to save the preference
197-
const submit = useSubmit();
198-
199-
const handleUsefulLinksToggle = (show: boolean) => {
205+
const toggleUsefulLinks = useCallback((show: boolean) => {
200206
setShowUsefulLinks(show);
201-
submit({ showUsefulLinks: show.toString() }, { method: "post" });
202-
};
207+
if (show) {
208+
usefulLinksPanelRef.current?.expand();
209+
} else {
210+
usefulLinksPanelRef.current?.collapse();
211+
}
212+
fetcherRef.current.submit({ showUsefulLinks: show.toString() }, { method: "post" });
213+
}, []);
203214

204215
return (
205216
<PageContainer>
@@ -226,27 +237,24 @@ export default function Page() {
226237
</NavBar>
227238
<PageBody scrollable={false}>
228239
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
229-
<ResizablePanel id="tasks-main" className="max-h-full">
240+
<ResizablePanel id="tasks-main" min="100px" className="max-h-full">
230241
<div className={cn("grid h-full grid-rows-1")}>
231242
{hasTasks ? (
232243
<div className="flex min-w-0 max-w-full flex-col">
233244
{tasks.length === 0 ? <UserHasNoTasks /> : null}
234245
<div className="max-h-full overflow-hidden">
235-
<div className="flex items-center gap-1 p-2">
236-
<Input
237-
placeholder="Search tasks"
238-
variant="tertiary"
239-
icon={MagnifyingGlassIcon}
240-
fullWidth={true}
246+
<div className="flex items-center justify-between gap-1 p-2">
247+
<AnimatedSearchField
241248
value={filterText}
242-
onChange={(e) => setFilterText(e.target.value)}
249+
onChange={setFilterText}
250+
placeholder="Search tasks…"
243251
autoFocus
244252
/>
245-
{!showUsefulLinks && (
253+
{!showUsefulLinks && (
246254
<Button
247-
variant="minimal/small"
255+
variant="secondary/small"
248256
TrailingIcon={LightBulbIcon}
249-
onClick={() => handleUsefulLinksToggle(true)}
257+
onClick={() => toggleUsefulLinks(true)}
250258
className="px-2.5"
251259
/>
252260
)}
@@ -417,20 +425,29 @@ export default function Page() {
417425
)}
418426
</div>
419427
</ResizablePanel>
420-
{hasTasks && showUsefulLinks ? (
421-
<>
422-
<ResizableHandle id="tasks-handle" />
423-
<ResizablePanel
424-
id="tasks-inspector"
425-
min="200px"
426-
default="400px"
427-
max="500px"
428-
className="w-full"
429-
>
430-
<HelpfulInfoHasTasks onClose={() => handleUsefulLinksToggle(false)} />
431-
</ResizablePanel>
432-
</>
433-
) : null}
428+
<ResizableHandle
429+
id="tasks-handle"
430+
className={collapsibleHandleClassName(hasTasks && showUsefulLinks)}
431+
/>
432+
<ResizablePanel
433+
id="tasks-inspector"
434+
handle={usefulLinksPanelRef}
435+
default="400px"
436+
min="400px"
437+
max="500px"
438+
className="overflow-hidden"
439+
collapsible
440+
collapsed={!hasTasks || !showUsefulLinks}
441+
onCollapseChange={() => {}}
442+
collapsedSize="0px"
443+
collapseAnimation={RESIZABLE_PANEL_ANIMATION}
444+
>
445+
<div className="h-full" style={{ minWidth: 400 }}>
446+
{hasTasks && (
447+
<HelpfulInfoHasTasks onClose={() => toggleUsefulLinks(false)} />
448+
)}
449+
</div>
450+
</ResizablePanel>
434451
</ResizablePanelGroup>
435452
</PageBody>
436453
</PageContainer>
@@ -850,3 +867,54 @@ function FailedToLoadStats() {
850867
/>
851868
);
852869
}
870+
871+
function AnimatedSearchField({
872+
value,
873+
onChange,
874+
placeholder,
875+
autoFocus,
876+
}: {
877+
value: string;
878+
onChange: (value: string) => void;
879+
placeholder?: string;
880+
autoFocus?: boolean;
881+
}) {
882+
const [isFocused, setIsFocused] = useState(false);
883+
884+
return (
885+
<motion.div
886+
initial={{ width: "auto" }}
887+
animate={{ width: isFocused && value.length > 0 ? "24rem" : "auto" }}
888+
transition={{ type: "spring", stiffness: 300, damping: 30 }}
889+
className="relative h-6 min-w-52"
890+
>
891+
<Input
892+
type="text"
893+
variant="secondary-small"
894+
placeholder={placeholder}
895+
value={value}
896+
onChange={(e) => onChange(e.target.value)}
897+
fullWidth
898+
autoFocus={autoFocus}
899+
className={cn(isFocused && "placeholder:text-text-dimmed/70")}
900+
onFocus={() => setIsFocused(true)}
901+
onBlur={() => setIsFocused(false)}
902+
onKeyDown={(e) => {
903+
if (e.key === "Escape") e.currentTarget.blur();
904+
}}
905+
icon={<MagnifyingGlassIcon className="size-4" />}
906+
accessory={
907+
value.length > 0 ? (
908+
<button
909+
type="button"
910+
onClick={() => onChange("")}
911+
className="flex size-4.5 items-center justify-center rounded-[2px] border border-text-dimmed/40 text-text-dimmed transition hover:bg-charcoal-600 hover:text-text-bright"
912+
>
913+
<XMarkIcon className="size-3" />
914+
</button>
915+
) : undefined
916+
}
917+
/>
918+
</motion.div>
919+
);
920+
}

0 commit comments

Comments
 (0)