Content-Length: 15454 | pFad | http://github.com/coder/coder/pull/18018.patch
thub.com
From 685848df3cdca232465931d45de2d0b8376844f3 Mon Sep 17 00:00:00 2001
From: McKayla Washburn
Date: Thu, 29 May 2025 17:28:22 +0000
Subject: [PATCH 1/2] feat: group apps together on workspace page
---
site/src/components/Button/Button.tsx | 42 ++++---
.../components/DropdownMenu/DropdownMenu.tsx | 12 +-
.../modules/resources/AgentRow.stories.tsx | 28 ++++-
site/src/modules/resources/AgentRow.test.tsx | 31 +++++
site/src/modules/resources/AgentRow.tsx | 113 ++++++++++++++++--
.../src/modules/resources/AppLink/AppLink.tsx | 19 ++-
site/src/testHelpers/entities.ts | 1 -
7 files changed, 215 insertions(+), 31 deletions(-)
create mode 100644 site/src/modules/resources/AgentRow.test.tsx
diff --git a/site/src/components/Button/Button.tsx b/site/src/components/Button/Button.tsx
index 908dacb8c5c3d..859aa10d0cf68 100644
--- a/site/src/components/Button/Button.tsx
+++ b/site/src/components/Button/Button.tsx
@@ -8,33 +8,45 @@ import { forwardRef } from "react";
import { cn } from "utils/cn";
const buttonVariants = cva(
- `inline-flex items-center justify-center gap-1 whitespace-nowrap font-sans
+ `
+ inline-flex items-center justify-center gap-1 whitespace-nowrap font-sans
border-solid rounded-md transition-colors
- text-sm font-semibold font-medium cursor-pointer no-underline
+ text-sm font-medium cursor-pointer no-underline
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link
disabled:pointer-events-none disabled:text-content-disabled
[&:is(a):not([href])]:pointer-events-none [&:is(a):not([href])]:text-content-disabled
[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:p-0.5
- [&>img]:pointer-events-none [&>img]:shrink-0 [&>img]:p-0.5`,
+ [&_img]:pointer-events-none [&_img]:shrink-0 [&_img]:p-0.5
+ `,
{
variants: {
variant: {
- default:
- "bg-surface-invert-primary text-content-invert hover:bg-surface-invert-secondary border-none disabled:bg-surface-secondary font-semibold",
- outline:
- "border border-border-default text-content-primary bg-transparent hover:bg-surface-secondary",
- subtle:
- "border-none bg-transparent text-content-secondary hover:text-content-primary",
- destructive:
- "border border-border-destructive text-content-primary bg-surface-destructive hover:bg-transparent disabled:bg-transparent disabled:text-content-disabled font-semibold",
+ default: `
+ border-none bg-surface-invert-primary font-semibold text-content-invert
+ hover:bg-surface-invert-secondary
+ disabled:bg-surface-secondary
+ `,
+ outline: `
+ border border-border-default bg-transparent text-content-primary
+ hover:bg-surface-secondary
+ `,
+ subtle: `
+ border-none bg-transparent text-content-secondary
+ hover:text-content-primary
+ `,
+ destructive: `
+ border border-border-destructive font-semibold text-content-primary bg-surface-destructive
+ hover:bg-transparent
+ disabled:bg-transparent disabled:text-content-disabled
+ `,
},
size: {
- lg: "min-w-20 h-10 px-3 py-2 [&_svg]:size-icon-lg [&>img]:size-icon-lg",
- sm: "min-w-20 h-8 px-2 py-1.5 text-xs [&_svg]:size-icon-sm [&>img]:size-icon-sm",
+ lg: "min-w-20 h-10 px-3 py-2 [&_svg]:size-icon-lg [&_img]:size-icon-lg",
+ sm: "min-w-20 h-8 px-2 py-1.5 text-xs [&_svg]:size-icon-sm [&_img]:size-icon-sm",
xs: "min-w-8 py-1 px-2 text-2xs rounded-md",
- icon: "size-8 px-1.5 [&_svg]:size-icon-sm [&>img]:size-icon-sm",
- "icon-lg": "size-10 px-2 [&_svg]:size-icon-lg [&>img]:size-icon-lg",
+ icon: "size-8 px-1.5 [&_svg]:size-icon-sm [&_img]:size-icon-sm",
+ "icon-lg": "size-10 px-2 [&_svg]:size-icon-lg [&_img]:size-icon-lg",
},
},
defaultVariants: {
diff --git a/site/src/components/DropdownMenu/DropdownMenu.tsx b/site/src/components/DropdownMenu/DropdownMenu.tsx
index 319ac3242067a..01547c30b17a6 100644
--- a/site/src/components/DropdownMenu/DropdownMenu.tsx
+++ b/site/src/components/DropdownMenu/DropdownMenu.tsx
@@ -109,9 +109,15 @@ export const DropdownMenuItem = forwardRef<
ref={ref}
className={cn(
[
- "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",
- "focus:bg-surface-secondary focus:text-content-primary data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
- "[&>svg]:size-4 [&>svg]:shrink-0 [&>img]:size-4 [&>img]:shrink-0 no-underline",
+ `
+ relative flex cursor-default select-none items-center gap-2 rounded-sm
+ px-2 py-1.5 text-sm text-content-secondary font-medium outline-none
+ no-underline
+ focus:bg-surface-secondary focus:text-content-primary
+ data-[disabled]:pointer-events-none data-[disabled]:opacity-50
+ [&_svg]:size-icon-sm [&>svg]:shrink-0
+ [&_img]:size-icon-sm [&>img]:shrink-0
+ `,
inset && "pl-8",
],
className,
diff --git a/site/src/modules/resources/AgentRow.stories.tsx b/site/src/modules/resources/AgentRow.stories.tsx
index 9d889ab0203eb..dbfb8da37a74b 100644
--- a/site/src/modules/resources/AgentRow.stories.tsx
+++ b/site/src/modules/resources/AgentRow.stories.tsx
@@ -1,5 +1,12 @@
import type { Meta, StoryObj } from "@storybook/react";
-import { spyOn } from "@storybook/test";
+import {
+ expect,
+ screen,
+ spyOn,
+ userEvent,
+ waitFor,
+ within,
+} from "@storybook/test";
import { API } from "api/api";
import { getPreferredProxy } from "contexts/ProxyContext";
import { chromatic } from "testHelpers/chromatic";
@@ -265,3 +272,22 @@ export const HideApp: Story = {
},
},
};
+
+export const GroupApp: Story = {
+ args: {
+ agent: {
+ ...M.MockWorkspaceAgent,
+ apps: [
+ {
+ ...M.MockWorkspaceApp,
+ group: "group",
+ },
+ ],
+ },
+ },
+
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(canvas.getByText("group"));
+ },
+};
diff --git a/site/src/modules/resources/AgentRow.test.tsx b/site/src/modules/resources/AgentRow.test.tsx
new file mode 100644
index 0000000000000..55b14704ad7a6
--- /dev/null
+++ b/site/src/modules/resources/AgentRow.test.tsx
@@ -0,0 +1,31 @@
+import { MockWorkspaceApp } from "testHelpers/entities";
+import { organizeAgentApps } from "./AgentRow";
+
+describe("organizeAgentApps", () => {
+ test("returns one ungrouped app", () => {
+ const result = organizeAgentApps([{ ...MockWorkspaceApp }]);
+
+ expect(result).toEqual([{ apps: [MockWorkspaceApp] }]);
+ });
+
+ test("handles ordering correctly", () => {
+ const bugApp = { ...MockWorkspaceApp, slug: "bug", group: "creatures" };
+ const birdApp = { ...MockWorkspaceApp, slug: "bird", group: "creatures" };
+ const fishApp = { ...MockWorkspaceApp, slug: "fish", group: "creatures" };
+ const riderApp = { ...MockWorkspaceApp, slug: "rider" };
+ const zedApp = { ...MockWorkspaceApp, slug: "zed" };
+ const result = organizeAgentApps([
+ bugApp,
+ riderApp,
+ birdApp,
+ zedApp,
+ fishApp,
+ ]);
+
+ expect(result).toEqual([
+ { group: "creatures", apps: [bugApp, birdApp, fishApp] },
+ { apps: [riderApp] },
+ { apps: [zedApp] },
+ ]);
+ });
+});
diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx
index a1db0e0390f3e..407c8c1bd84c6 100644
--- a/site/src/modules/resources/AgentRow.tsx
+++ b/site/src/modules/resources/AgentRow.tsx
@@ -9,12 +9,19 @@ import type {
Workspace,
WorkspaceAgent,
WorkspaceAgentMetadata,
+ WorkspaceApp,
} from "api/typesGenerated";
import { isAxiosError } from "axios";
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
-import type { Line } from "components/Logs/LogLine";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "components/DropdownMenu/DropdownMenu";
import { Stack } from "components/Stack/Stack";
import { useProxy } from "contexts/ProxyContext";
+import { Folder } from "lucide-react";
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
import { AppStatuses } from "pages/WorkspacePage/AppStatuses";
import {
@@ -29,6 +36,7 @@ import {
import { useQuery } from "react-query";
import AutoSizer from "react-virtualized-auto-sizer";
import type { FixedSizeList as List, ListOnScrollProps } from "react-window";
+import { AgentButton } from "./AgentButton";
import { AgentDevcontainerCard } from "./AgentDevcontainerCard";
import { AgentLatency } from "./AgentLatency";
import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogs/AgentLogLine";
@@ -59,10 +67,10 @@ export const AgentRow: FC = ({
onUpdateAgent,
initialMetadata,
}) => {
- // Apps visibility
const { browser_only } = useFeatureVisibility();
- const visibleApps = agent.apps.filter((app) => !app.hidden);
- const hasAppsToDisplay = !browser_only && visibleApps.length > 0;
+ const appSections = organizeAgentApps(agent.apps);
+ const hasAppsToDisplay =
+ !browser_only || appSections.some((it) => it.apps.length > 0);
const shouldDisplayApps =
(agent.status === "connected" && hasAppsToDisplay) ||
agent.status === "connecting";
@@ -223,10 +231,10 @@ export const AgentRow: FC = ({
displayApps={agent.display_apps}
/>
)}
- {visibleApps.map((app) => (
- (
+
@@ -296,7 +304,7 @@ export const AgentRow: FC = ({
width={width}
css={styles.startupLogs}
onScroll={handleLogScroll}
- logs={startupLogs.map((l) => ({
+ logs={startupLogs.map((l) => ({
id: l.id,
level: l.level,
output: l.output,
@@ -327,6 +335,93 @@ export const AgentRow: FC = ({
);
};
+type AppSection = {
+ /**
+ * If there is no `group`, just render all of the apps inline. If there is a
+ * group name, show them all in a dropdown.
+ */
+ group?: string;
+
+ apps: WorkspaceApp[];
+};
+
+/**
+ * organizeAgentApps returns an ordering of agent apps that accounts for
+ * grouping. When we receive the list of apps from the backend, they have
+ * already been "ordered" by their `order` attribute, but we are not given that
+ * value. We must be careful to preserve that ordering, while also properly
+ * grouping together all apps of any given group.
+ *
+ * The position of the group overall is determined by the `order` position of
+ * the first app in the group. There may be several sections returned without
+ * a group name, to allow placing grouped apps in between non-grouped apps. Not
+ * every ungrouped section is expected to have a group in between, to make the
+ * algorithm a little simpler to implement.
+ */
+export function organizeAgentApps(apps: readonly WorkspaceApp[]): AppSection[] {
+ let currentSection: AppSection | undefined = undefined;
+ const appGroups: AppSection[] = [];
+ const groupsByName = new Map();
+
+ for (const app of apps) {
+ if (app.hidden) {
+ continue;
+ }
+
+ if (!currentSection || app.group !== currentSection.group) {
+ const existingSection = groupsByName.get(app.group!);
+ if (existingSection) {
+ currentSection = existingSection;
+ } else {
+ currentSection = {
+ group: app.group,
+ apps: [],
+ };
+ appGroups.push(currentSection);
+ if (app.group) {
+ groupsByName.set(app.group, currentSection);
+ }
+ }
+ }
+
+ currentSection.apps.push(app);
+ }
+
+ return appGroups;
+}
+
+type AppsProps = {
+ section: AppSection;
+ agent: WorkspaceAgent;
+ workspace: Workspace;
+};
+
+const Apps: FC = ({ section, agent, workspace }) => {
+ return section.group ? (
+
+
+
+
+ {section.group}
+
+
+
+ {section.apps.map((app) => (
+
+
+
+ ))}
+
+
+ ) : (
+ <>
+ {section.apps.map((app) => (
+
+ ))}
+ >
+ );
+};
+
const styles = {
agentRow: (theme) => ({
fontSize: 14,
diff --git a/site/src/modules/resources/AppLink/AppLink.tsx b/site/src/modules/resources/AppLink/AppLink.tsx
index 74542abc710aa..637f0287a4088 100644
--- a/site/src/modules/resources/AppLink/AppLink.tsx
+++ b/site/src/modules/resources/AppLink/AppLink.tsx
@@ -1,5 +1,6 @@
import { useTheme } from "@emotion/react";
import type * as TypesGen from "api/typesGenerated";
+import { DropdownMenuItem } from "components/DropdownMenu/DropdownMenu";
import { Spinner } from "components/Spinner/Spinner";
import {
Tooltip,
@@ -28,9 +29,15 @@ interface AppLinkProps {
workspace: TypesGen.Workspace;
app: TypesGen.WorkspaceApp;
agent: TypesGen.WorkspaceAgent;
+ grouped?: boolean;
}
-export const AppLink: FC = ({ app, workspace, agent }) => {
+export const AppLink: FC = ({
+ app,
+ workspace,
+ agent,
+ grouped,
+}) => {
const { proxy } = useProxy();
const host = proxy.preferredWildcardHostname;
const [iconError, setIconError] = useState(false);
@@ -90,7 +97,15 @@ export const AppLink: FC = ({ app, workspace, agent }) => {
const canShare = app.sharing_level !== "owner";
- const button = (
+ const button = grouped ? (
+
+
+ {icon}
+ {link.label}
+ {canShare && }
+
+
+ ) : (
{icon}
diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts
index 72db7f6644d30..fa16d65dc8d5b 100644
--- a/site/src/testHelpers/entities.ts
+++ b/site/src/testHelpers/entities.ts
@@ -903,7 +903,6 @@ export const MockWorkspaceApp: TypesGen.WorkspaceApp = {
health: "disabled",
external: false,
sharing_level: "owner",
- group: "",
hidden: false,
open_in: "slim-window",
statuses: [],
From b053f32c6159c12e23e4a4d1212ab9d77c256422 Mon Sep 17 00:00:00 2001
From: McKayla Washburn
Date: Thu, 29 May 2025 17:38:31 +0000
Subject: [PATCH 2/2] linting
---
site/src/modules/resources/AgentRow.stories.tsx | 9 +--------
1 file changed, 1 insertion(+), 8 deletions(-)
diff --git a/site/src/modules/resources/AgentRow.stories.tsx b/site/src/modules/resources/AgentRow.stories.tsx
index dbfb8da37a74b..4444dbbac1c77 100644
--- a/site/src/modules/resources/AgentRow.stories.tsx
+++ b/site/src/modules/resources/AgentRow.stories.tsx
@@ -1,12 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
-import {
- expect,
- screen,
- spyOn,
- userEvent,
- waitFor,
- within,
-} from "@storybook/test";
+import { spyOn, userEvent, within } from "@storybook/test";
import { API } from "api/api";
import { getPreferredProxy } from "contexts/ProxyContext";
import { chromatic } from "testHelpers/chromatic";
--- a PPN by Garber Painting Akron. With Image Size Reduction included!Fetched URL: http://github.com/coder/coder/pull/18018.patch
Alternative Proxies:
Alternative Proxy
pFad Proxy
pFad v3 Proxy
pFad v4 Proxy