Skip to content

Commit 232c72f

Browse files
authored
feat: group apps together on workspace page (#18018)
1 parent e906ce2 commit 232c72f

File tree

7 files changed

+208
-31
lines changed

7 files changed

+208
-31
lines changed

site/src/components/Button/Button.tsx

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,33 +8,45 @@ import { forwardRef } from "react";
88
import { cn } from "utils/cn";
99

1010
const buttonVariants = cva(
11-
`inline-flex items-center justify-center gap-1 whitespace-nowrap font-sans
11+
`
12+
inline-flex items-center justify-center gap-1 whitespace-nowrap font-sans
1213
border-solid rounded-md transition-colors
13-
text-sm font-semibold font-medium cursor-pointer no-underline
14+
text-sm font-medium cursor-pointer no-underline
1415
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link
1516
disabled:pointer-events-none disabled:text-content-disabled
1617
[&:is(a):not([href])]:pointer-events-none [&:is(a):not([href])]:text-content-disabled
1718
[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:p-0.5
18-
[&>img]:pointer-events-none [&>img]:shrink-0 [&>img]:p-0.5`,
19+
[&_img]:pointer-events-none [&_img]:shrink-0 [&_img]:p-0.5
20+
`,
1921
{
2022
variants: {
2123
variant: {
22-
default:
23-
"bg-surface-invert-primary text-content-invert hover:bg-surface-invert-secondary border-none disabled:bg-surface-secondary font-semibold",
24-
outline:
25-
"border border-border-default text-content-primary bg-transparent hover:bg-surface-secondary",
26-
subtle:
27-
"border-none bg-transparent text-content-secondary hover:text-content-primary",
28-
destructive:
29-
"border border-border-destructive text-content-primary bg-surface-destructive hover:bg-transparent disabled:bg-transparent disabled:text-content-disabled font-semibold",
24+
default: `
25+
border-none bg-surface-invert-primary font-semibold text-content-invert
26+
hover:bg-surface-invert-secondary
27+
disabled:bg-surface-secondary
28+
`,
29+
outline: `
30+
border border-border-default bg-transparent text-content-primary
31+
hover:bg-surface-secondary
32+
`,
33+
subtle: `
34+
border-none bg-transparent text-content-secondary
35+
hover:text-content-primary
36+
`,
37+
destructive: `
38+
border border-border-destructive font-semibold text-content-primary bg-surface-destructive
39+
hover:bg-transparent
40+
disabled:bg-transparent disabled:text-content-disabled
41+
`,
3042
},
3143

3244
size: {
33-
lg: "min-w-20 h-10 px-3 py-2 [&_svg]:size-icon-lg [&>img]:size-icon-lg",
34-
sm: "min-w-20 h-8 px-2 py-1.5 text-xs [&_svg]:size-icon-sm [&>img]:size-icon-sm",
45+
lg: "min-w-20 h-10 px-3 py-2 [&_svg]:size-icon-lg [&_img]:size-icon-lg",
46+
sm: "min-w-20 h-8 px-2 py-1.5 text-xs [&_svg]:size-icon-sm [&_img]:size-icon-sm",
3547
xs: "min-w-8 py-1 px-2 text-2xs rounded-md",
36-
icon: "size-8 px-1.5 [&_svg]:size-icon-sm [&>img]:size-icon-sm",
37-
"icon-lg": "size-10 px-2 [&_svg]:size-icon-lg [&>img]:size-icon-lg",
48+
icon: "size-8 px-1.5 [&_svg]:size-icon-sm [&_img]:size-icon-sm",
49+
"icon-lg": "size-10 px-2 [&_svg]:size-icon-lg [&_img]:size-icon-lg",
3850
},
3951
},
4052
defaultVariants: {

site/src/components/DropdownMenu/DropdownMenu.tsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,15 @@ export const DropdownMenuItem = forwardRef<
109109
ref={ref}
110110
className={cn(
111111
[
112-
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-2 text-sm text-content-secondary font-medium outline-none transition-colors",
113-
"focus:bg-surface-secondary focus:text-content-primary data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
114-
"[&>svg]:size-4 [&>svg]:shrink-0 [&>img]:size-4 [&>img]:shrink-0 no-underline",
112+
`
113+
relative flex cursor-default select-none items-center gap-2 rounded-sm
114+
px-2 py-1.5 text-sm text-content-secondary font-medium outline-none
115+
no-underline
116+
focus:bg-surface-secondary focus:text-content-primary
117+
data-[disabled]:pointer-events-none data-[disabled]:opacity-50
118+
[&_svg]:size-icon-sm [&>svg]:shrink-0
119+
[&_img]:size-icon-sm [&>img]:shrink-0
120+
`,
115121
inset && "pl-8",
116122
],
117123
className,

site/src/modules/resources/AgentRow.stories.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Meta, StoryObj } from "@storybook/react";
2-
import { spyOn } from "@storybook/test";
2+
import { spyOn, userEvent, within } from "@storybook/test";
33
import { API } from "api/api";
44
import { getPreferredProxy } from "contexts/ProxyContext";
55
import { chromatic } from "testHelpers/chromatic";
@@ -265,3 +265,22 @@ export const HideApp: Story = {
265265
},
266266
},
267267
};
268+
269+
export const GroupApp: Story = {
270+
args: {
271+
agent: {
272+
...M.MockWorkspaceAgent,
273+
apps: [
274+
{
275+
...M.MockWorkspaceApp,
276+
group: "group",
277+
},
278+
],
279+
},
280+
},
281+
282+
play: async ({ canvasElement }) => {
283+
const canvas = within(canvasElement);
284+
await userEvent.click(canvas.getByText("group"));
285+
},
286+
};
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { MockWorkspaceApp } from "testHelpers/entities";
2+
import { organizeAgentApps } from "./AgentRow";
3+
4+
describe("organizeAgentApps", () => {
5+
test("returns one ungrouped app", () => {
6+
const result = organizeAgentApps([{ ...MockWorkspaceApp }]);
7+
8+
expect(result).toEqual([{ apps: [MockWorkspaceApp] }]);
9+
});
10+
11+
test("handles ordering correctly", () => {
12+
const bugApp = { ...MockWorkspaceApp, slug: "bug", group: "creatures" };
13+
const birdApp = { ...MockWorkspaceApp, slug: "bird", group: "creatures" };
14+
const fishApp = { ...MockWorkspaceApp, slug: "fish", group: "creatures" };
15+
const riderApp = { ...MockWorkspaceApp, slug: "rider" };
16+
const zedApp = { ...MockWorkspaceApp, slug: "zed" };
17+
const result = organizeAgentApps([
18+
bugApp,
19+
riderApp,
20+
birdApp,
21+
zedApp,
22+
fishApp,
23+
]);
24+
25+
expect(result).toEqual([
26+
{ group: "creatures", apps: [bugApp, birdApp, fishApp] },
27+
{ apps: [riderApp] },
28+
{ apps: [zedApp] },
29+
]);
30+
});
31+
});

site/src/modules/resources/AgentRow.tsx

Lines changed: 104 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,19 @@ import type {
99
Workspace,
1010
WorkspaceAgent,
1111
WorkspaceAgentMetadata,
12+
WorkspaceApp,
1213
} from "api/typesGenerated";
1314
import { isAxiosError } from "axios";
1415
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
15-
import type { Line } from "components/Logs/LogLine";
16+
import {
17+
DropdownMenu,
18+
DropdownMenuContent,
19+
DropdownMenuItem,
20+
DropdownMenuTrigger,
21+
} from "components/DropdownMenu/DropdownMenu";
1622
import { Stack } from "components/Stack/Stack";
1723
import { useProxy } from "contexts/ProxyContext";
24+
import { Folder } from "lucide-react";
1825
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
1926
import { AppStatuses } from "pages/WorkspacePage/AppStatuses";
2027
import {
@@ -29,6 +36,7 @@ import {
2936
import { useQuery } from "react-query";
3037
import AutoSizer from "react-virtualized-auto-sizer";
3138
import type { FixedSizeList as List, ListOnScrollProps } from "react-window";
39+
import { AgentButton } from "./AgentButton";
3240
import { AgentDevcontainerCard } from "./AgentDevcontainerCard";
3341
import { AgentLatency } from "./AgentLatency";
3442
import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogs/AgentLogLine";
@@ -59,10 +67,10 @@ export const AgentRow: FC<AgentRowProps> = ({
5967
onUpdateAgent,
6068
initialMetadata,
6169
}) => {
62-
// Apps visibility
6370
const { browser_only } = useFeatureVisibility();
64-
const visibleApps = agent.apps.filter((app) => !app.hidden);
65-
const hasAppsToDisplay = !browser_only && visibleApps.length > 0;
71+
const appSections = organizeAgentApps(agent.apps);
72+
const hasAppsToDisplay =
73+
!browser_only || appSections.some((it) => it.apps.length > 0);
6674
const shouldDisplayApps =
6775
(agent.status === "connected" && hasAppsToDisplay) ||
6876
agent.status === "connecting";
@@ -223,10 +231,10 @@ export const AgentRow: FC<AgentRowProps> = ({
223231
displayApps={agent.display_apps}
224232
/>
225233
)}
226-
{visibleApps.map((app) => (
227-
<AppLink
228-
key={app.slug}
229-
app={app}
234+
{appSections.map((section, i) => (
235+
<Apps
236+
key={section.group ?? i}
237+
section={section}
230238
agent={agent}
231239
workspace={workspace}
232240
/>
@@ -296,7 +304,7 @@ export const AgentRow: FC<AgentRowProps> = ({
296304
width={width}
297305
css={styles.startupLogs}
298306
onScroll={handleLogScroll}
299-
logs={startupLogs.map<Line>((l) => ({
307+
logs={startupLogs.map((l) => ({
300308
id: l.id,
301309
level: l.level,
302310
output: l.output,
@@ -327,6 +335,93 @@ export const AgentRow: FC<AgentRowProps> = ({
327335
);
328336
};
329337

338+
type AppSection = {
339+
/**
340+
* If there is no `group`, just render all of the apps inline. If there is a
341+
* group name, show them all in a dropdown.
342+
*/
343+
group?: string;
344+
345+
apps: WorkspaceApp[];
346+
};
347+
348+
/**
349+
* organizeAgentApps returns an ordering of agent apps that accounts for
350+
* grouping. When we receive the list of apps from the backend, they have
351+
* already been "ordered" by their `order` attribute, but we are not given that
352+
* value. We must be careful to preserve that ordering, while also properly
353+
* grouping together all apps of any given group.
354+
*
355+
* The position of the group overall is determined by the `order` position of
356+
* the first app in the group. There may be several sections returned without
357+
* a group name, to allow placing grouped apps in between non-grouped apps. Not
358+
* every ungrouped section is expected to have a group in between, to make the
359+
* algorithm a little simpler to implement.
360+
*/
361+
export function organizeAgentApps(apps: readonly WorkspaceApp[]): AppSection[] {
362+
let currentSection: AppSection | undefined = undefined;
363+
const appGroups: AppSection[] = [];
364+
const groupsByName = new Map<string, AppSection>();
365+
366+
for (const app of apps) {
367+
if (app.hidden) {
368+
continue;
369+
}
370+
371+
if (!currentSection || app.group !== currentSection.group) {
372+
const existingSection = groupsByName.get(app.group!);
373+
if (existingSection) {
374+
currentSection = existingSection;
375+
} else {
376+
currentSection = {
377+
group: app.group,
378+
apps: [],
379+
};
380+
appGroups.push(currentSection);
381+
if (app.group) {
382+
groupsByName.set(app.group, currentSection);
383+
}
384+
}
385+
}
386+
387+
currentSection.apps.push(app);
388+
}
389+
390+
return appGroups;
391+
}
392+
393+
type AppsProps = {
394+
section: AppSection;
395+
agent: WorkspaceAgent;
396+
workspace: Workspace;
397+
};
398+
399+
const Apps: FC<AppsProps> = ({ section, agent, workspace }) => {
400+
return section.group ? (
401+
<DropdownMenu>
402+
<DropdownMenuTrigger asChild>
403+
<AgentButton>
404+
<Folder />
405+
{section.group}
406+
</AgentButton>
407+
</DropdownMenuTrigger>
408+
<DropdownMenuContent align="start">
409+
{section.apps.map((app) => (
410+
<DropdownMenuItem key={app.slug}>
411+
<AppLink grouped app={app} agent={agent} workspace={workspace} />
412+
</DropdownMenuItem>
413+
))}
414+
</DropdownMenuContent>
415+
</DropdownMenu>
416+
) : (
417+
<>
418+
{section.apps.map((app) => (
419+
<AppLink key={app.slug} app={app} agent={agent} workspace={workspace} />
420+
))}
421+
</>
422+
);
423+
};
424+
330425
const styles = {
331426
agentRow: (theme) => ({
332427
fontSize: 14,

site/src/modules/resources/AppLink/AppLink.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useTheme } from "@emotion/react";
22
import type * as TypesGen from "api/typesGenerated";
3+
import { DropdownMenuItem } from "components/DropdownMenu/DropdownMenu";
34
import { Spinner } from "components/Spinner/Spinner";
45
import {
56
Tooltip,
@@ -28,9 +29,15 @@ interface AppLinkProps {
2829
workspace: TypesGen.Workspace;
2930
app: TypesGen.WorkspaceApp;
3031
agent: TypesGen.WorkspaceAgent;
32+
grouped?: boolean;
3133
}
3234

33-
export const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
35+
export const AppLink: FC<AppLinkProps> = ({
36+
app,
37+
workspace,
38+
agent,
39+
grouped,
40+
}) => {
3441
const { proxy } = useProxy();
3542
const host = proxy.preferredWildcardHostname;
3643
const [iconError, setIconError] = useState(false);
@@ -90,7 +97,15 @@ export const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
9097

9198
const canShare = app.sharing_level !== "owner";
9299

93-
const button = (
100+
const button = grouped ? (
101+
<DropdownMenuItem asChild>
102+
<a href={canClick ? link.href : undefined} onClick={link.onClick}>
103+
{icon}
104+
{link.label}
105+
{canShare && <ShareIcon app={app} />}
106+
</a>
107+
</DropdownMenuItem>
108+
) : (
94109
<AgentButton asChild>
95110
<a href={canClick ? link.href : undefined} onClick={link.onClick}>
96111
{icon}

site/src/testHelpers/entities.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -903,7 +903,6 @@ export const MockWorkspaceApp: TypesGen.WorkspaceApp = {
903903
health: "disabled",
904904
external: false,
905905
sharing_level: "owner",
906-
group: "",
907906
hidden: false,
908907
open_in: "slim-window",
909908
statuses: [],

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy