Skip to content

Commit 232c72f

Browse files
authored
feat: group apps together on workspace page (coder#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)