mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
feat: group apps together on workspace page (#18018)
This commit is contained in:
@ -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: {
|
||||
|
@ -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,
|
||||
|
@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { spyOn } 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";
|
||||
@ -265,3 +265,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"));
|
||||
},
|
||||
};
|
||||
|
31
site/src/modules/resources/AgentRow.test.tsx
Normal file
31
site/src/modules/resources/AgentRow.test.tsx
Normal file
@ -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] },
|
||||
]);
|
||||
});
|
||||
});
|
@ -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<AgentRowProps> = ({
|
||||
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<AgentRowProps> = ({
|
||||
displayApps={agent.display_apps}
|
||||
/>
|
||||
)}
|
||||
{visibleApps.map((app) => (
|
||||
<AppLink
|
||||
key={app.slug}
|
||||
app={app}
|
||||
{appSections.map((section, i) => (
|
||||
<Apps
|
||||
key={section.group ?? i}
|
||||
section={section}
|
||||
agent={agent}
|
||||
workspace={workspace}
|
||||
/>
|
||||
@ -296,7 +304,7 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
width={width}
|
||||
css={styles.startupLogs}
|
||||
onScroll={handleLogScroll}
|
||||
logs={startupLogs.map<Line>((l) => ({
|
||||
logs={startupLogs.map((l) => ({
|
||||
id: l.id,
|
||||
level: l.level,
|
||||
output: l.output,
|
||||
@ -327,6 +335,93 @@ export const AgentRow: FC<AgentRowProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
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<string, AppSection>();
|
||||
|
||||
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<AppsProps> = ({ section, agent, workspace }) => {
|
||||
return section.group ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<AgentButton>
|
||||
<Folder />
|
||||
{section.group}
|
||||
</AgentButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
{section.apps.map((app) => (
|
||||
<DropdownMenuItem key={app.slug}>
|
||||
<AppLink grouped app={app} agent={agent} workspace={workspace} />
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<>
|
||||
{section.apps.map((app) => (
|
||||
<AppLink key={app.slug} app={app} agent={agent} workspace={workspace} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
agentRow: (theme) => ({
|
||||
fontSize: 14,
|
||||
|
@ -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<AppLinkProps> = ({ app, workspace, agent }) => {
|
||||
export const AppLink: FC<AppLinkProps> = ({
|
||||
app,
|
||||
workspace,
|
||||
agent,
|
||||
grouped,
|
||||
}) => {
|
||||
const { proxy } = useProxy();
|
||||
const host = proxy.preferredWildcardHostname;
|
||||
const [iconError, setIconError] = useState(false);
|
||||
@ -90,7 +97,15 @@ export const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
|
||||
|
||||
const canShare = app.sharing_level !== "owner";
|
||||
|
||||
const button = (
|
||||
const button = grouped ? (
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={canClick ? link.href : undefined} onClick={link.onClick}>
|
||||
{icon}
|
||||
{link.label}
|
||||
{canShare && <ShareIcon app={app} />}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<AgentButton asChild>
|
||||
<a href={canClick ? link.href : undefined} onClick={link.onClick}>
|
||||
{icon}
|
||||
|
@ -903,7 +903,6 @@ export const MockWorkspaceApp: TypesGen.WorkspaceApp = {
|
||||
health: "disabled",
|
||||
external: false,
|
||||
sharing_level: "owner",
|
||||
group: "",
|
||||
hidden: false,
|
||||
open_in: "slim-window",
|
||||
statuses: [],
|
||||
|
Reference in New Issue
Block a user