feat: Enable custom support links (#6313)

* backend: support links

* frontend: Support links

* fmt

* test: CODER_SUPPORT_LINKS_0_NAME

* Go tests

* Use UpdateAppearanceConfig

* ui: UpdateAppearanceConfig

* fix: fmt

* Fix: site

* Fix: site tests

* fix: fmt

* fix

* test: check default support links
This commit is contained in:
Marcin Tojek
2023-02-27 09:25:04 +01:00
committed by GitHub
parent 16364db483
commit 2e53fb55da
20 changed files with 641 additions and 90 deletions
+27 -1
View File
@@ -34,6 +34,7 @@ export interface AppHostResponse {
export interface AppearanceConfig {
readonly logo_url: string
readonly service_banner: ServiceBannerConfig
readonly support_links?: LinkConfig[]
}
// From codersdk/roles.go
@@ -334,6 +335,7 @@ export interface DeploymentConfig {
readonly disable_password_auth: DeploymentConfigField<boolean>
readonly address: DeploymentConfigField<string>
readonly experimental: DeploymentConfigField<boolean>
readonly support: SupportConfig
}
// From codersdk/deployment.go
@@ -434,6 +436,13 @@ export interface License {
readonly claims: Record<string, any>
}
// From codersdk/deployment.go
export interface LinkConfig {
readonly name: string
readonly target: string
readonly icon: string
}
// From codersdk/deployment.go
export interface LoggingConfig {
readonly human: DeploymentConfigField<string>
@@ -657,6 +666,11 @@ export interface ServiceBannerConfig {
readonly background_color?: string
}
// From codersdk/deployment.go
export interface SupportConfig {
readonly links: DeploymentConfigField<LinkConfig[]>
}
// From codersdk/deployment.go
export interface SwaggerConfig {
readonly enable: DeploymentConfigField<boolean>
@@ -818,6 +832,12 @@ export interface UpdateActiveTemplateVersion {
readonly id: string
}
// From codersdk/deployment.go
export interface UpdateAppearanceConfig {
readonly logo_url: string
readonly service_banner: ServiceBannerConfig
}
// From codersdk/updatecheck.go
export interface UpdateCheckResponse {
readonly current: boolean
@@ -1331,4 +1351,10 @@ export const WorkspaceTransitions: WorkspaceTransition[] = [
]
// From codersdk/deployment.go
export type Flaggable = string | number | boolean | string[] | GitAuthConfig[]
export type Flaggable =
| string
| number
| boolean
| string[]
| GitAuthConfig[]
| LinkConfig[]
+1
View File
@@ -22,6 +22,7 @@ export const Navbar: FC = () => {
user={me}
logo_url={appearance.config.logo_url}
buildInfo={buildInfo}
supportLinks={appearance.config.support_links}
onSignOut={onSignOut}
canViewAuditLog={canViewAuditLog}
canViewDeployment={canViewDeployment}
@@ -19,6 +19,7 @@ export interface NavbarViewProps {
logo_url?: string
user?: TypesGen.User
buildInfo?: TypesGen.BuildInfoResponse
supportLinks?: TypesGen.LinkConfig[]
onSignOut: () => void
canViewAuditLog: boolean
canViewDeployment: boolean
@@ -86,6 +87,7 @@ export const NavbarView: React.FC<React.PropsWithChildren<NavbarViewProps>> = ({
user,
logo_url,
buildInfo,
supportLinks,
onSignOut,
canViewAuditLog,
canViewDeployment,
@@ -147,6 +149,7 @@ export const NavbarView: React.FC<React.PropsWithChildren<NavbarViewProps>> = ({
<UserDropdown
user={user}
buildInfo={buildInfo}
supportLinks={supportLinks}
onSignOut={onSignOut}
/>
)}
@@ -1,5 +1,5 @@
import { fireEvent, screen } from "@testing-library/react"
import { MockUser } from "../../testHelpers/entities"
import { MockSupportLinks, MockUser } from "../../testHelpers/entities"
import { render } from "../../testHelpers/renderHelpers"
import { Language } from "../UserDropdownContent/UserDropdownContent"
import { UserDropdown, UserDropdownProps } from "./UsersDropdown"
@@ -8,6 +8,7 @@ const renderAndClick = async (props: Partial<UserDropdownProps> = {}) => {
render(
<UserDropdown
user={props.user ?? MockUser}
supportLinks={MockSupportLinks}
onSignOut={props.onSignOut ?? jest.fn()}
/>,
)
@@ -20,7 +21,9 @@ describe("UserDropdown", () => {
it("opens the menu", async () => {
await renderAndClick()
expect(screen.getByText(Language.accountLabel)).toBeDefined()
expect(screen.getByText(Language.docsLabel)).toBeDefined()
expect(screen.getByText(MockSupportLinks[0].name)).toBeDefined()
expect(screen.getByText(MockSupportLinks[1].name)).toBeDefined()
expect(screen.getByText(MockSupportLinks[2].name)).toBeDefined()
expect(screen.getByText(Language.signOutLabel)).toBeDefined()
})
})
@@ -13,12 +13,14 @@ import { UserDropdownContent } from "../UserDropdownContent/UserDropdownContent"
export interface UserDropdownProps {
user: TypesGen.User
buildInfo?: TypesGen.BuildInfoResponse
supportLinks?: TypesGen.LinkConfig[]
onSignOut: () => void
}
export const UserDropdown: FC<PropsWithChildren<UserDropdownProps>> = ({
buildInfo,
user,
supportLinks,
onSignOut,
}: UserDropdownProps) => {
const styles = useStyles()
@@ -69,6 +71,7 @@ export const UserDropdown: FC<PropsWithChildren<UserDropdownProps>> = ({
<UserDropdownContent
user={user}
buildInfo={buildInfo}
supportLinks={supportLinks}
onPopoverClose={onPopoverClose}
onSignOut={onSignOut}
/>
@@ -1,5 +1,9 @@
import { screen } from "@testing-library/react"
import { MockBuildInfo, MockUser } from "../../testHelpers/entities"
import {
MockBuildInfo,
MockSupportLinks,
MockUser,
} from "../../testHelpers/entities"
import { render } from "../../testHelpers/renderHelpers"
import { Language, UserDropdownContent } from "./UserDropdownContent"
@@ -21,16 +25,23 @@ describe("UserDropdownContent", () => {
<UserDropdownContent
user={MockUser}
buildInfo={MockBuildInfo}
supportLinks={MockSupportLinks}
onSignOut={jest.fn()}
onPopoverClose={jest.fn()}
/>,
)
expect(screen.getByText(Language.accountLabel)).toBeDefined()
expect(screen.getByText(Language.docsLabel)).toBeDefined()
expect(screen.getByText(Language.signOutLabel)).toBeDefined()
expect(screen.getByText(Language.bugLabel)).toBeDefined()
expect(screen.getByText(Language.discordLabel)).toBeDefined()
expect(screen.getByText(Language.copyrightText)).toBeDefined()
expect(screen.getByText(MockSupportLinks[0].name)).toBeDefined()
expect(screen.getByText(MockSupportLinks[1].name)).toBeDefined()
expect(screen.getByText(MockSupportLinks[2].name)).toBeDefined()
expect(
screen.getByText(MockSupportLinks[2].name).closest("a"),
).toHaveAttribute(
"href",
"https://github.com/coder/coder/issues/new?labels=needs+grooming&body=Version%3A%20%5B%60v99.999.9999%2Bc9cdf14%60%5D(file%3A%2F%2F%2Fmock-url)",
)
expect(screen.getByText(MockBuildInfo.version)).toBeDefined()
})
@@ -15,16 +15,14 @@ import { combineClasses } from "util/combineClasses"
export const Language = {
accountLabel: "Account",
docsLabel: "Documentation",
signOutLabel: "Sign Out",
bugLabel: "Report a Bug",
discordLabel: "Join the Coder Discord",
copyrightText: `\u00a9 ${new Date().getFullYear()} Coder Technologies, Inc.`,
}
export interface UserDropdownContentProps {
user: TypesGen.User
buildInfo?: TypesGen.BuildInfoResponse
supportLinks?: TypesGen.LinkConfig[]
onPopoverClose: () => void
onSignOut: () => void
}
@@ -32,14 +30,11 @@ export interface UserDropdownContentProps {
export const UserDropdownContent: FC<UserDropdownContentProps> = ({
buildInfo,
user,
supportLinks,
onPopoverClose,
onSignOut,
}) => {
const styles = useStyles()
const githubUrl = `https://github.com/coder/coder/issues/new?labels=needs+grooming&body=${encodeURIComponent(`Version: [\`${buildInfo?.version}\`](${buildInfo?.external_url})
<!--- Ask a question or leave feedback! -->`)}`
const discordUrl = `https://coder.com/chat?utm_source=coder&utm_medium=coder&utm_campaign=server-footer`
return (
<div>
@@ -64,43 +59,33 @@ export const UserDropdownContent: FC<UserDropdownContentProps> = ({
<Divider className={styles.divider} />
<a
href="https://coder.com/docs/coder-oss"
target="_blank"
rel="noreferrer"
className={styles.link}
>
<MenuItem className={styles.menuItem} onClick={onPopoverClose}>
<DocsIcon className={styles.menuItemIcon} />
<span className={styles.menuItemText}>{Language.docsLabel}</span>
</MenuItem>
</a>
<>
{supportLinks &&
supportLinks.map((link) => (
<a
href={includeBuildInfo(link.target, buildInfo)}
key={link.name}
target="_blank"
rel="noreferrer"
className={styles.link}
>
<MenuItem className={styles.menuItem} onClick={onPopoverClose}>
{link.icon === "bug" && (
<BugIcon className={styles.menuItemIcon} />
)}
{link.icon === "chat" && (
<ChatIcon className={styles.menuItemIcon} />
)}
{link.icon === "docs" && (
<DocsIcon className={styles.menuItemIcon} />
)}
<span className={styles.menuItemText}>{link.name}</span>
</MenuItem>
</a>
))}
</>
<a
href={githubUrl}
target="_blank"
rel="noreferrer"
className={styles.link}
>
<MenuItem className={styles.menuItem} onClick={onPopoverClose}>
<BugIcon className={styles.menuItemIcon} />
<span className={styles.menuItemText}>{Language.bugLabel}</span>
</MenuItem>
</a>
<a
href={discordUrl}
target="_blank"
rel="noreferrer"
className={styles.link}
>
<MenuItem className={styles.menuItem} onClick={onPopoverClose}>
<ChatIcon className={styles.menuItemIcon} />
<span className={styles.menuItemText}>{Language.discordLabel}</span>
</MenuItem>
</a>
<Divider className={styles.divider} />
{supportLinks && <Divider className={styles.divider} />}
<Stack className={styles.info} spacing={0}>
<a
@@ -181,3 +166,15 @@ const useStyles = makeStyles((theme) => ({
color: theme.palette.text.primary,
},
}))
const includeBuildInfo = (
href: string,
buildInfo?: TypesGen.BuildInfoResponse,
): string => {
return href.replace(
"{CODER_BUILD_INFO}",
`${encodeURIComponent(
`Version: [\`${buildInfo?.version}\`](${buildInfo?.external_url})`,
)}`,
)
}
@@ -1,4 +1,4 @@
import { AppearanceConfig } from "api/typesGenerated"
import { UpdateAppearanceConfig } from "api/typesGenerated"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
@@ -15,7 +15,7 @@ const AppearanceSettingsPage: FC = () => {
entitlements.features["appearance"].entitlement !== "not_entitled"
const updateAppearance = (
newConfig: Partial<AppearanceConfig>,
newConfig: Partial<UpdateAppearanceConfig>,
preview: boolean,
) => {
const newAppearance = {
@@ -17,16 +17,16 @@ import { useTranslation } from "react-i18next"
import makeStyles from "@material-ui/core/styles/makeStyles"
import Switch from "@material-ui/core/Switch"
import TextField from "@material-ui/core/TextField"
import { AppearanceConfig } from "api/typesGenerated"
import { UpdateAppearanceConfig } from "api/typesGenerated"
import { Stack } from "components/Stack/Stack"
import { useFormik } from "formik"
import { useTheme } from "@material-ui/core/styles"
export type AppearanceSettingsPageViewProps = {
appearance: AppearanceConfig
appearance: UpdateAppearanceConfig
isEntitled: boolean
updateAppearance: (
newConfig: Partial<AppearanceConfig>,
newConfig: Partial<UpdateAppearanceConfig>,
preview: boolean,
) => void
}
@@ -48,20 +48,22 @@ export const AppearanceSettingsPageView = ({
})
const logoFieldHelpers = getFormHelpers(logoForm)
const serviceBannerForm = useFormik<AppearanceConfig["service_banner"]>({
initialValues: {
message: appearance.service_banner.message,
enabled: appearance.service_banner.enabled,
background_color: appearance.service_banner.background_color,
const serviceBannerForm = useFormik<UpdateAppearanceConfig["service_banner"]>(
{
initialValues: {
message: appearance.service_banner.message,
enabled: appearance.service_banner.enabled,
background_color: appearance.service_banner.background_color,
},
onSubmit: (values) =>
updateAppearance(
{
service_banner: values,
},
false,
),
},
onSubmit: (values) =>
updateAppearance(
{
service_banner: values,
},
false,
),
})
)
const serviceBannerFieldHelpers = getFormHelpers(serviceBannerForm)
const [backgroundColor, setBackgroundColor] = useState(
serviceBannerForm.values.background_color,
+19
View File
@@ -60,6 +60,25 @@ export const MockBuildInfo: TypesGen.BuildInfoResponse = {
version: "v99.999.9999+c9cdf14",
}
export const MockSupportLinks: TypesGen.LinkConfig[] = [
{
name: "First link",
target: "http://first-link",
icon: "chat",
},
{
name: "Second link",
target: "http://second-link",
icon: "docs",
},
{
name: "Third link",
target:
"https://github.com/coder/coder/issues/new?labels=needs+grooming&body={CODER_BUILD_INFO}",
icon: "",
},
]
export const MockUpdateCheck: TypesGen.UpdateCheckResponse = {
current: true,
url: "file:///mock-url",