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";
|
import { cn } from "utils/cn";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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
|
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
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link
|
||||||
disabled:pointer-events-none disabled:text-content-disabled
|
disabled:pointer-events-none disabled:text-content-disabled
|
||||||
[&:is(a):not([href])]:pointer-events-none [&:is(a):not([href])]: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
|
[&_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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default: `
|
||||||
"bg-surface-invert-primary text-content-invert hover:bg-surface-invert-secondary border-none disabled:bg-surface-secondary font-semibold",
|
border-none bg-surface-invert-primary font-semibold text-content-invert
|
||||||
outline:
|
hover:bg-surface-invert-secondary
|
||||||
"border border-border-default text-content-primary bg-transparent hover:bg-surface-secondary",
|
disabled:bg-surface-secondary
|
||||||
subtle:
|
`,
|
||||||
"border-none bg-transparent text-content-secondary hover:text-content-primary",
|
outline: `
|
||||||
destructive:
|
border border-border-default bg-transparent text-content-primary
|
||||||
"border border-border-destructive text-content-primary bg-surface-destructive hover:bg-transparent disabled:bg-transparent disabled:text-content-disabled font-semibold",
|
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: {
|
size: {
|
||||||
lg: "min-w-20 h-10 px-3 py-2 [&_svg]:size-icon-lg [&>img]:size-icon-lg",
|
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",
|
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",
|
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: "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-lg": "size-10 px-2 [&_svg]:size-icon-lg [&_img]:size-icon-lg",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
@ -109,9 +109,15 @@ export const DropdownMenuItem = forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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",
|
relative flex cursor-default select-none items-center gap-2 rounded-sm
|
||||||
"[&>svg]:size-4 [&>svg]:shrink-0 [&>img]:size-4 [&>img]:shrink-0 no-underline",
|
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",
|
inset && "pl-8",
|
||||||
],
|
],
|
||||||
className,
|
className,
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from "@storybook/react";
|
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 { API } from "api/api";
|
||||||
import { getPreferredProxy } from "contexts/ProxyContext";
|
import { getPreferredProxy } from "contexts/ProxyContext";
|
||||||
import { chromatic } from "testHelpers/chromatic";
|
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,
|
Workspace,
|
||||||
WorkspaceAgent,
|
WorkspaceAgent,
|
||||||
WorkspaceAgentMetadata,
|
WorkspaceAgentMetadata,
|
||||||
|
WorkspaceApp,
|
||||||
} from "api/typesGenerated";
|
} from "api/typesGenerated";
|
||||||
import { isAxiosError } from "axios";
|
import { isAxiosError } from "axios";
|
||||||
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
|
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 { Stack } from "components/Stack/Stack";
|
||||||
import { useProxy } from "contexts/ProxyContext";
|
import { useProxy } from "contexts/ProxyContext";
|
||||||
|
import { Folder } from "lucide-react";
|
||||||
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
import { useFeatureVisibility } from "modules/dashboard/useFeatureVisibility";
|
||||||
import { AppStatuses } from "pages/WorkspacePage/AppStatuses";
|
import { AppStatuses } from "pages/WorkspacePage/AppStatuses";
|
||||||
import {
|
import {
|
||||||
@ -29,6 +36,7 @@ import {
|
|||||||
import { useQuery } from "react-query";
|
import { useQuery } from "react-query";
|
||||||
import AutoSizer from "react-virtualized-auto-sizer";
|
import AutoSizer from "react-virtualized-auto-sizer";
|
||||||
import type { FixedSizeList as List, ListOnScrollProps } from "react-window";
|
import type { FixedSizeList as List, ListOnScrollProps } from "react-window";
|
||||||
|
import { AgentButton } from "./AgentButton";
|
||||||
import { AgentDevcontainerCard } from "./AgentDevcontainerCard";
|
import { AgentDevcontainerCard } from "./AgentDevcontainerCard";
|
||||||
import { AgentLatency } from "./AgentLatency";
|
import { AgentLatency } from "./AgentLatency";
|
||||||
import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogs/AgentLogLine";
|
import { AGENT_LOG_LINE_HEIGHT } from "./AgentLogs/AgentLogLine";
|
||||||
@ -59,10 +67,10 @@ export const AgentRow: FC<AgentRowProps> = ({
|
|||||||
onUpdateAgent,
|
onUpdateAgent,
|
||||||
initialMetadata,
|
initialMetadata,
|
||||||
}) => {
|
}) => {
|
||||||
// Apps visibility
|
|
||||||
const { browser_only } = useFeatureVisibility();
|
const { browser_only } = useFeatureVisibility();
|
||||||
const visibleApps = agent.apps.filter((app) => !app.hidden);
|
const appSections = organizeAgentApps(agent.apps);
|
||||||
const hasAppsToDisplay = !browser_only && visibleApps.length > 0;
|
const hasAppsToDisplay =
|
||||||
|
!browser_only || appSections.some((it) => it.apps.length > 0);
|
||||||
const shouldDisplayApps =
|
const shouldDisplayApps =
|
||||||
(agent.status === "connected" && hasAppsToDisplay) ||
|
(agent.status === "connected" && hasAppsToDisplay) ||
|
||||||
agent.status === "connecting";
|
agent.status === "connecting";
|
||||||
@ -223,10 +231,10 @@ export const AgentRow: FC<AgentRowProps> = ({
|
|||||||
displayApps={agent.display_apps}
|
displayApps={agent.display_apps}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{visibleApps.map((app) => (
|
{appSections.map((section, i) => (
|
||||||
<AppLink
|
<Apps
|
||||||
key={app.slug}
|
key={section.group ?? i}
|
||||||
app={app}
|
section={section}
|
||||||
agent={agent}
|
agent={agent}
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
/>
|
/>
|
||||||
@ -296,7 +304,7 @@ export const AgentRow: FC<AgentRowProps> = ({
|
|||||||
width={width}
|
width={width}
|
||||||
css={styles.startupLogs}
|
css={styles.startupLogs}
|
||||||
onScroll={handleLogScroll}
|
onScroll={handleLogScroll}
|
||||||
logs={startupLogs.map<Line>((l) => ({
|
logs={startupLogs.map((l) => ({
|
||||||
id: l.id,
|
id: l.id,
|
||||||
level: l.level,
|
level: l.level,
|
||||||
output: l.output,
|
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 = {
|
const styles = {
|
||||||
agentRow: (theme) => ({
|
agentRow: (theme) => ({
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { useTheme } from "@emotion/react";
|
import { useTheme } from "@emotion/react";
|
||||||
import type * as TypesGen from "api/typesGenerated";
|
import type * as TypesGen from "api/typesGenerated";
|
||||||
|
import { DropdownMenuItem } from "components/DropdownMenu/DropdownMenu";
|
||||||
import { Spinner } from "components/Spinner/Spinner";
|
import { Spinner } from "components/Spinner/Spinner";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@ -28,9 +29,15 @@ interface AppLinkProps {
|
|||||||
workspace: TypesGen.Workspace;
|
workspace: TypesGen.Workspace;
|
||||||
app: TypesGen.WorkspaceApp;
|
app: TypesGen.WorkspaceApp;
|
||||||
agent: TypesGen.WorkspaceAgent;
|
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 { proxy } = useProxy();
|
||||||
const host = proxy.preferredWildcardHostname;
|
const host = proxy.preferredWildcardHostname;
|
||||||
const [iconError, setIconError] = useState(false);
|
const [iconError, setIconError] = useState(false);
|
||||||
@ -90,7 +97,15 @@ export const AppLink: FC<AppLinkProps> = ({ app, workspace, agent }) => {
|
|||||||
|
|
||||||
const canShare = app.sharing_level !== "owner";
|
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>
|
<AgentButton asChild>
|
||||||
<a href={canClick ? link.href : undefined} onClick={link.onClick}>
|
<a href={canClick ? link.href : undefined} onClick={link.onClick}>
|
||||||
{icon}
|
{icon}
|
||||||
|
@ -903,7 +903,6 @@ export const MockWorkspaceApp: TypesGen.WorkspaceApp = {
|
|||||||
health: "disabled",
|
health: "disabled",
|
||||||
external: false,
|
external: false,
|
||||||
sharing_level: "owner",
|
sharing_level: "owner",
|
||||||
group: "",
|
|
||||||
hidden: false,
|
hidden: false,
|
||||||
open_in: "slim-window",
|
open_in: "slim-window",
|
||||||
statuses: [],
|
statuses: [],
|
||||||
|
Reference in New Issue
Block a user