feat: add support buttons (#20339)

Fixes: https://github.com/coder/coder/issues/16804
This commit is contained in:
Marcin Tojek
2025-10-22 15:35:16 +02:00
committed by GitHub
parent aa689cbb39
commit f2a410566c
16 changed files with 237 additions and 73 deletions
+9 -1
View File
@@ -14897,7 +14897,15 @@ const docTemplate = `{
"enum": [
"bug",
"chat",
"docs"
"docs",
"star"
]
},
"location": {
"type": "string",
"enum": [
"navbar",
"dropdown"
]
},
"name": {
+5 -1
View File
@@ -13489,7 +13489,11 @@
"properties": {
"icon": {
"type": "string",
"enum": ["bug", "chat", "docs"]
"enum": ["bug", "chat", "docs", "star"]
},
"location": {
"type": "string",
"enum": ["navbar", "dropdown"]
},
"name": {
"type": "string"
+7 -4
View File
@@ -984,9 +984,10 @@ func DefaultSupportLinks(docsURL string) []LinkConfig {
Icon: "bug",
},
{
Name: "Join the Coder Discord",
Target: "https://coder.com/chat?utm_source=coder&utm_medium=coder&utm_campaign=server-footer",
Icon: "chat",
Name: "Join the Coder Discord",
Target: "https://discord.gg/coder",
Icon: "chat",
Location: "navbar",
},
{
Name: "Star the Repo",
@@ -3339,7 +3340,9 @@ type SupportConfig struct {
type LinkConfig struct {
Name string `json:"name" yaml:"name"`
Target string `json:"target" yaml:"target"`
Icon string `json:"icon" yaml:"icon" enums:"bug,chat,docs"`
Icon string `json:"icon" yaml:"icon" enums:"bug,chat,docs,star"`
Location string `json:"location,omitempty" yaml:"location,omitempty" enums:"navbar,dropdown"`
}
// Validate checks cross-field constraints for deployment values.
+1
View File
@@ -120,6 +120,7 @@ curl -X GET http://coder-server:8080/api/v2/appearance \
"support_links": [
{
"icon": "bug",
"location": "navbar",
"name": "string",
"target": "string"
}
+1
View File
@@ -473,6 +473,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
"value": [
{
"icon": "bug",
"location": "navbar",
"name": "string",
"target": "string"
}
+20 -10
View File
@@ -1110,6 +1110,7 @@
"support_links": [
{
"icon": "bug",
"location": "navbar",
"name": "string",
"target": "string"
}
@@ -3138,6 +3139,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"value": [
{
"icon": "bug",
"location": "navbar",
"name": "string",
"target": "string"
}
@@ -3644,6 +3646,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
"value": [
{
"icon": "bug",
"location": "navbar",
"name": "string",
"target": "string"
}
@@ -4752,6 +4755,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
```json
{
"icon": "bug",
"location": "navbar",
"name": "string",
"target": "string"
}
@@ -4759,19 +4763,23 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
### Properties
| Name | Type | Required | Restrictions | Description |
|----------|--------|----------|--------------|-------------|
| `icon` | string | false | | |
| `name` | string | false | | |
| `target` | string | false | | |
| Name | Type | Required | Restrictions | Description |
|------------|--------|----------|--------------|-------------|
| `icon` | string | false | | |
| `location` | string | false | | |
| `name` | string | false | | |
| `target` | string | false | | |
#### Enumerated Values
| Property | Value |
|----------|--------|
| `icon` | `bug` |
| `icon` | `chat` |
| `icon` | `docs` |
| Property | Value |
|------------|------------|
| `icon` | `bug` |
| `icon` | `chat` |
| `icon` | `docs` |
| `icon` | `star` |
| `location` | `navbar` |
| `location` | `dropdown` |
## codersdk.ListInboxNotificationsResponse
@@ -7625,6 +7633,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit|
"value": [
{
"icon": "bug",
"location": "navbar",
"name": "string",
"target": "string"
}
@@ -13685,6 +13694,7 @@ None
"value": [
{
"icon": "bug",
"location": "navbar",
"name": "string",
"target": "string"
}
+11
View File
@@ -201,6 +201,17 @@ func TestCustomSupportLinks(t *testing.T) {
Target: "http://second-link-2",
Icon: "bug",
},
{
Name: "First button",
Target: "http://first-button-1",
Icon: "bug",
Location: "navbar",
},
{
Name: "Third link",
Target: "http://third-link-3",
Icon: "star",
},
}
cfg := coderdtest.DeploymentValues(t)
cfg.Support.Links = serpent.Struct[[]codersdk.LinkConfig]{
+1
View File
@@ -2515,6 +2515,7 @@ export interface LinkConfig {
readonly name: string;
readonly target: string;
readonly icon: string;
readonly location?: string;
}
// From codersdk/inboxnotification.go
+8 -1
View File
@@ -1,4 +1,5 @@
import { buildInfo } from "api/queries/buildInfo";
import type { LinkConfig } from "api/typesGenerated";
import { useProxy } from "contexts/ProxyContext";
import { useAuthenticated } from "hooks";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
@@ -25,12 +26,18 @@ export const Navbar: FC = () => {
const canViewConnectionLog =
featureVisibility.connection_log && permissions.viewAnyConnectionLog;
const uniqueLinks = new Map<string, LinkConfig>();
for (const link of appearance.support_links ?? []) {
if (!uniqueLinks.has(link.name)) {
uniqueLinks.set(link.name, link);
}
}
return (
<NavbarView
user={me}
logo_url={appearance.logo_url}
buildInfo={buildInfoQuery.data}
supportLinks={appearance.support_links}
supportLinks={Array.from(uniqueLinks.values())}
onSignOut={signOut}
canViewDeployment={canViewDeployment}
canViewOrganizations={canViewOrganizations}
@@ -33,6 +33,7 @@ const meta: Meta<typeof NavbarView> = {
canViewDeployment: true,
canViewHealth: true,
canViewOrganizations: true,
supportLinks: [],
},
decorators: [withDashboardProvider],
};
@@ -129,3 +130,64 @@ export const IdleTasks: Story = {
],
},
};
export const SupportLinks: Story = {
args: {
user: MockUserMember,
canViewAuditLog: false,
canViewDeployment: false,
canViewHealth: false,
canViewOrganizations: false,
supportLinks: [
{
name: "This is a bug",
icon: "bug",
target: "#",
},
{
name: "This is a star",
icon: "star",
target: "#",
location: "navbar",
},
{
name: "This is a chat",
icon: "chat",
target: "#",
location: "navbar",
},
{
name: "No icon here",
icon: "",
target: "#",
location: "navbar",
},
{
name: "No icon here too",
icon: "",
target: "#",
},
],
},
};
export const DefaultSupportLinks: Story = {
args: {
user: MockUserMember,
canViewAuditLog: false,
canViewDeployment: false,
canViewHealth: false,
canViewOrganizations: false,
supportLinks: [
{ icon: "docs", name: "Documentation", target: "" },
{ icon: "bug", name: "Report a bug", target: "" },
{
icon: "chat",
name: "Join the Coder Discord",
target: "",
location: "navbar",
},
{ icon: "star", name: "Star the Repo", target: "" },
],
},
};
@@ -34,6 +34,7 @@ describe("NavbarView", () => {
canViewHealth
canViewAuditLog
canViewConnectionLog
supportLinks={[]}
/>,
);
const workspacesLink =
@@ -52,6 +53,7 @@ describe("NavbarView", () => {
canViewHealth
canViewAuditLog
canViewConnectionLog
supportLinks={[]}
/>,
);
const templatesLink =
@@ -70,6 +72,7 @@ describe("NavbarView", () => {
canViewHealth
canViewAuditLog
canViewConnectionLog
supportLinks={[]}
/>,
);
const deploymentMenu = await screen.findByText("Admin settings");
@@ -89,6 +92,7 @@ describe("NavbarView", () => {
canViewHealth
canViewAuditLog
canViewConnectionLog
supportLinks={[]}
/>,
);
const deploymentMenu = await screen.findByText("Admin settings");
@@ -21,13 +21,14 @@ import { cn } from "utils/cn";
import { DeploymentDropdown } from "./DeploymentDropdown";
import { MobileMenu } from "./MobileMenu";
import { ProxyMenu } from "./ProxyMenu";
import { SupportIcon } from "./SupportIcon";
import { UserDropdown } from "./UserDropdown/UserDropdown";
interface NavbarViewProps {
logo_url?: string;
user: TypesGen.User;
buildInfo?: TypesGen.BuildInfoResponse;
supportLinks?: readonly TypesGen.LinkConfig[];
supportLinks: readonly TypesGen.LinkConfig[];
onSignOut: () => void;
canViewDeployment: boolean;
canViewOrganizations: boolean;
@@ -71,6 +72,16 @@ export const NavbarView: FC<NavbarViewProps> = ({
<NavItems className="ml-4" user={user} />
<div className="flex items-center gap-3 ml-auto">
{supportLinks.filter(isNavbarLink).map((link) => (
<div key={link.name} className="hidden md:block">
<SupportButton
name={link.name}
target={link.target}
icon={link.icon}
/>
</div>
))}
{proxyContextValue && (
<div className="hidden md:block">
<ProxyMenu proxyContextValue={proxyContextValue} />
@@ -121,7 +132,7 @@ export const NavbarView: FC<NavbarViewProps> = ({
<UserDropdown
user={user}
buildInfo={buildInfo}
supportLinks={supportLinks}
supportLinks={supportLinks?.filter((link) => !isNavbarLink(link))}
onSignOut={onSignOut}
/>
</div>
@@ -240,3 +251,36 @@ const TasksNavItem: FC<TasksNavItemProps> = ({ user }) => {
function idleTasksLabel(count: number) {
return `You have ${count} ${count === 1 ? "task" : "tasks"} waiting for input`;
}
function isNavbarLink(link: TypesGen.LinkConfig): boolean {
return link.location === "navbar";
}
interface SupportButtonProps {
name: string;
target: string;
icon: string;
location?: string;
}
const SupportButton: FC<SupportButtonProps> = ({ name, target, icon }) => {
return (
<Button asChild variant="outline">
<a
href={target}
target="_blank"
rel="noreferrer"
className="inline-block"
>
{icon && (
<SupportIcon
icon={icon}
className={"size-5 text-content-secondary"}
/>
)}
{name}
<span className="sr-only"> (link opens in new tab)</span>
</a>
</Button>
);
};
@@ -0,0 +1,39 @@
import type { SvgIconProps } from "@mui/material/SvgIcon";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { BookOpenTextIcon, BugIcon, MessageSquareIcon } from "lucide-react";
import type { FC } from "react";
interface SupportIconProps {
icon: string;
className?: string;
}
export const SupportIcon: FC<SupportIconProps> = ({ icon, className }) => {
switch (icon) {
case "bug":
return <BugIcon className={className} />;
case "chat":
return <MessageSquareIcon className={className} />;
case "docs":
return <BookOpenTextIcon className={className} />;
case "star":
return <GithubStar className={className} />;
default:
return <ExternalImage src={icon} className={className} />;
}
};
const GithubStar: FC<SvgIconProps> = (props) => (
<svg
aria-hidden="true"
height="16"
viewBox="0 0 16 16"
version="1.1"
width="16"
data-view-component="true"
fill="currentColor"
{...props}
>
<path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z" />
</svg>
);
@@ -11,7 +11,7 @@ import { UserDropdownContent } from "./UserDropdownContent";
interface UserDropdownProps {
user: TypesGen.User;
buildInfo?: TypesGen.BuildInfoResponse;
supportLinks?: readonly TypesGen.LinkConfig[];
supportLinks: readonly TypesGen.LinkConfig[];
onSignOut: () => void;
}
@@ -8,7 +8,11 @@ describe("UserDropdownContent", () => {
it("has the correct link for the account item", async () => {
render(
<Popover>
<UserDropdownContent user={MockUserOwner} onSignOut={jest.fn()} />
<UserDropdownContent
user={MockUserOwner}
onSignOut={jest.fn()}
supportLinks={[]}
/>
</Popover>,
);
await waitForLoaderToBeRemoved();
@@ -25,7 +29,11 @@ describe("UserDropdownContent", () => {
const onSignOut = jest.fn();
render(
<Popover>
<UserDropdownContent user={MockUserOwner} onSignOut={onSignOut} />
<UserDropdownContent
user={MockUserOwner}
onSignOut={onSignOut}
supportLinks={[]}
/>
</Popover>,
);
await waitForLoaderToBeRemoved();
@@ -6,24 +6,20 @@ import {
} from "@emotion/react";
import Divider from "@mui/material/Divider";
import MenuItem from "@mui/material/MenuItem";
import type { SvgIconProps } from "@mui/material/SvgIcon";
import Tooltip from "@mui/material/Tooltip";
import { PopoverClose } from "@radix-ui/react-popover";
import type * as TypesGen from "api/typesGenerated";
import { CopyButton } from "components/CopyButton/CopyButton";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { Stack } from "components/Stack/Stack";
import {
BookOpenTextIcon,
BugIcon,
CircleUserIcon,
LogOutIcon,
MessageSquareIcon,
MonitorDownIcon,
SquareArrowOutUpRightIcon,
} from "lucide-react";
import type { FC, JSX } from "react";
import type { FC } from "react";
import { Link } from "react-router";
import { SupportIcon } from "../SupportIcon";
export const Language = {
accountLabel: "Account",
@@ -34,7 +30,7 @@ export const Language = {
interface UserDropdownContentProps {
user: TypesGen.User;
buildInfo?: TypesGen.BuildInfoResponse;
supportLinks?: readonly TypesGen.LinkConfig[];
supportLinks: readonly TypesGen.LinkConfig[];
onSignOut: () => void;
}
@@ -44,26 +40,6 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({
supportLinks,
onSignOut,
}) => {
const renderMenuIcon = (icon: string): JSX.Element => {
switch (icon) {
case "bug":
return <BugIcon css={styles.menuItemIcon} />;
case "chat":
return <MessageSquareIcon css={styles.menuItemIcon} />;
case "docs":
return <BookOpenTextIcon css={styles.menuItemIcon} />;
case "star":
return <GithubStar css={styles.menuItemIcon} />;
default:
return (
<ExternalImage
src={icon}
css={{ maxWidth: "20px", maxHeight: "20px" }}
/>
);
}
};
return (
<div>
<Stack css={styles.info} spacing={0}>
@@ -76,7 +52,7 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({
<Link to="/install" css={styles.link}>
<PopoverClose asChild>
<MenuItem css={styles.menuItem}>
<MonitorDownIcon css={styles.menuItemIcon} />
<MonitorDownIcon className="size-5 text-content-secondary" />
<span css={styles.menuItemText}>Install CLI</span>
</MenuItem>
</PopoverClose>
@@ -85,14 +61,14 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({
<Link to="/settings/account" css={styles.link}>
<PopoverClose asChild>
<MenuItem css={styles.menuItem}>
<CircleUserIcon css={styles.menuItemIcon} />
<CircleUserIcon className="size-5 text-content-secondary" />
<span css={styles.menuItemText}>{Language.accountLabel}</span>
</MenuItem>
</PopoverClose>
</Link>
<MenuItem css={styles.menuItem} onClick={onSignOut}>
<LogOutIcon css={styles.menuItemIcon} />
<LogOutIcon className="size-5 text-content-secondary" />
<span css={styles.menuItemText}>{Language.signOutLabel}</span>
</MenuItem>
@@ -109,7 +85,12 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({
>
<PopoverClose asChild>
<MenuItem css={styles.menuItem}>
{renderMenuIcon(link.icon)}
{link.icon && (
<SupportIcon
icon={link.icon}
className="size-5 text-content-secondary"
/>
)}
<span css={styles.menuItemText}>{link.name}</span>
</MenuItem>
</PopoverClose>
@@ -152,21 +133,6 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({
);
};
const GithubStar: FC<SvgIconProps> = (props) => (
<svg
aria-hidden="true"
height="16"
viewBox="0 0 16 16"
version="1.1"
width="16"
data-view-component="true"
fill="currentColor"
{...props}
>
<path d="M8 .25a.75.75 0 0 1 .673.418l1.882 3.815 4.21.612a.75.75 0 0 1 .416 1.279l-3.046 2.97.719 4.192a.751.751 0 0 1-1.088.791L8 12.347l-3.766 1.98a.75.75 0 0 1-1.088-.79l.72-4.194L.818 6.374a.75.75 0 0 1 .416-1.28l4.21-.611L7.327.668A.75.75 0 0 1 8 .25Z" />
</svg>
);
const styles = {
info: (theme) => [
theme.typography.body2 as CSSObject,
@@ -196,11 +162,6 @@ const styles = {
transition: background-color 0.3s ease;
}
`,
menuItemIcon: (theme) => ({
color: theme.palette.text.secondary,
width: 20,
height: 20,
}),
menuItemText: {
fontSize: 14,
},