diff --git a/dogfood/coder/main.tf b/dogfood/coder/main.tf index 63430109589c0..c983102eb9fca 100644 --- a/dogfood/coder/main.tf +++ b/dogfood/coder/main.tf @@ -130,11 +130,29 @@ data "coder_parameter" "image_type" { } } +locals { + default_regions = { + // keys should match group names + "north-america" : "us-pittsburgh" + "europe" : "eu-helsinki" + "australia" : "ap-sydney" + "south-america" : "sa-saopaulo" + "africa" : "za-cpt" + } + + user_groups = data.coder_workspace_owner.me.groups + user_region = coalescelist([ + for g in local.user_groups : + local.default_regions[g] if contains(keys(local.default_regions), g) + ], ["us-pittsburgh"])[0] +} + + data "coder_parameter" "region" { type = "string" name = "Region" icon = "/emojis/1f30e.png" - default = "us-pittsburgh" + default = local.user_region option { icon = "/emojis/1f1fa-1f1f8.png" name = "Pittsburgh" 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..4444dbbac1c77 100644 --- a/site/src/modules/resources/AgentRow.stories.tsx +++ b/site/src/modules/resources/AgentRow.stories.tsx @@ -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")); + }, +}; 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: [],