feat: Add LicenseBanner (#3568)

* Extract reusable Pill component

* Make icon optional

* Get pills in place

* Rough styling

* Extract Expander component

* Fix alignment

* Put it in action - type error

* Hide banner by default

* Use generated type

* Move PaletteIndex type

* Tweak colors

* Format, another color tweak

* Add stories

* Add tests

* Update site/src/components/Pill/Pill.tsx

Co-authored-by: Kira Pilot <kira@coder.com>

* Update site/src/components/Pill/Pill.tsx

Co-authored-by: Kira Pilot <kira@coder.com>

* Comments

* Remove empty story, improve empty test

* Lint

Co-authored-by: Kira Pilot <kira@coder.com>
This commit is contained in:
Presley Pizzo
2022-08-23 11:26:22 -04:00
committed by GitHub
co-authored by Kira Pilot
parent f7ccfa2ab9
commit 49de44c76d
18 changed files with 509 additions and 97 deletions
+5
View File
@@ -378,3 +378,8 @@ export const putWorkspaceExtension = async (
): Promise<void> => {
await axios.put(`/api/v2/workspaces/${workspaceId}/extend`, { deadline: newDeadline })
}
export const getEntitlements = async (): Promise<TypesGen.Entitlements> => {
const response = await axios.get("/api/v2/entitlements")
return response.data
}
+2
View File
@@ -1,5 +1,6 @@
import CssBaseline from "@material-ui/core/CssBaseline"
import ThemeProvider from "@material-ui/styles/ThemeProvider"
import { LicenseBanner } from "components/LicenseBanner/LicenseBanner"
import { FC } from "react"
import { HelmetProvider } from "react-helmet-async"
import { BrowserRouter as Router } from "react-router-dom"
@@ -18,6 +19,7 @@ export const App: FC = () => {
<CssBaseline />
<ErrorBoundary>
<XServiceProvider>
<LicenseBanner />
<AppRouter />
<GlobalSnackbar />
</XServiceProvider>
@@ -3,10 +3,10 @@ import KeyboardArrowDown from "@material-ui/icons/KeyboardArrowDown"
import KeyboardArrowUp from "@material-ui/icons/KeyboardArrowUp"
import { FC } from "react"
const useStyles = makeStyles((theme: Theme) => ({
const useStyles = makeStyles<Theme, ArrowProps>((theme: Theme) => ({
arrowIcon: {
color: fade(theme.palette.primary.contrastText, 0.7),
marginLeft: theme.spacing(1),
marginLeft: ({ margin }) => (margin ? theme.spacing(1) : 0),
width: 16,
height: 16,
},
@@ -15,12 +15,16 @@ const useStyles = makeStyles((theme: Theme) => ({
},
}))
export const OpenDropdown: FC = () => {
const styles = useStyles()
interface ArrowProps {
margin?: boolean
}
export const OpenDropdown: FC<ArrowProps> = ({ margin = true }) => {
const styles = useStyles({ margin })
return <KeyboardArrowDown className={styles.arrowIcon} />
}
export const CloseDropdown: FC = () => {
const styles = useStyles()
export const CloseDropdown: FC<ArrowProps> = ({ margin = true }) => {
const styles = useStyles({ margin })
return <KeyboardArrowUp className={`${styles.arrowIcon} ${styles.arrowIconUp}`} />
}
@@ -1,11 +1,11 @@
import Button from "@material-ui/core/Button"
import Collapse from "@material-ui/core/Collapse"
import IconButton from "@material-ui/core/IconButton"
import Link from "@material-ui/core/Link"
import { darken, lighten, makeStyles, Theme } from "@material-ui/core/styles"
import CloseIcon from "@material-ui/icons/Close"
import RefreshIcon from "@material-ui/icons/Refresh"
import { ApiError, getErrorDetail, getErrorMessage } from "api/errors"
import { Expander } from "components/Expander/Expander"
import { Stack } from "components/Stack/Stack"
import { FC, useState } from "react"
@@ -36,10 +36,6 @@ export const ErrorSummary: FC<React.PropsWithChildren<ErrorSummaryProps>> = ({
const styles = useStyles({ showDetails })
const toggleShowDetails = () => {
setShowDetails(!showDetails)
}
const closeError = () => {
setOpen(false)
}
@@ -51,19 +47,10 @@ export const ErrorSummary: FC<React.PropsWithChildren<ErrorSummaryProps>> = ({
return (
<Stack className={styles.root}>
<Stack direction="row" alignItems="center" className={styles.messageBox}>
<div>
<Stack direction="row" spacing={0}>
<span className={styles.errorMessage}>{message}</span>
{!!detail && (
<Link
aria-expanded={showDetails}
onClick={toggleShowDetails}
className={styles.detailsLink}
tabIndex={0}
>
{showDetails ? Language.lessDetails : Language.moreDetails}
</Link>
)}
</div>
{!!detail && <Expander expanded={showDetails} setExpanded={setShowDetails} />}
</Stack>
{dismissible && (
<IconButton onClick={closeError} className={styles.iconButton}>
<CloseIcon className={styles.closeIcon} />
@@ -101,6 +88,9 @@ const useStyles = makeStyles<Theme, StyleProps>((theme) => ({
borderRadius: theme.shape.borderRadius,
gap: 0,
},
flex: {
display: "flex",
},
messageBox: {
justifyContent: "space-between",
},
@@ -0,0 +1,22 @@
import { Story } from "@storybook/react"
import { Expander, ExpanderProps } from "./Expander"
export default {
title: "components/Expander",
component: Expander,
argTypes: {
setExpanded: { action: "setExpanded" },
},
}
const Template: Story<ExpanderProps> = (args) => <Expander {...args} />
export const Expanded = Template.bind({})
Expanded.args = {
expanded: true,
}
export const Collapsed = Template.bind({})
Collapsed.args = {
expanded: false,
}
+45
View File
@@ -0,0 +1,45 @@
import Link from "@material-ui/core/Link"
import makeStyles from "@material-ui/core/styles/makeStyles"
import { CloseDropdown, OpenDropdown } from "components/DropdownArrows/DropdownArrows"
const Language = {
expand: "More",
collapse: "Less",
}
export interface ExpanderProps {
expanded: boolean
setExpanded: (val: boolean) => void
}
export const Expander: React.FC<ExpanderProps> = ({ expanded, setExpanded }) => {
const toggleExpanded = () => setExpanded(!expanded)
const styles = useStyles()
return (
<Link aria-expanded={expanded} onClick={toggleExpanded} className={styles.expandLink}>
{expanded ? (
<span className={styles.text}>
{Language.collapse}
<CloseDropdown margin={false} />{" "}
</span>
) : (
<span className={styles.text}>
{Language.expand}
<OpenDropdown margin={false} />
</span>
)}
</Link>
)
}
const useStyles = makeStyles((theme) => ({
expandLink: {
cursor: "pointer",
color: theme.palette.text.primary,
display: "flex",
},
text: {
display: "flex",
alignItems: "center",
},
}))
@@ -0,0 +1,27 @@
import { screen } from "@testing-library/react"
import { rest } from "msw"
import { MockEntitlementsWithWarnings } from "testHelpers/entities"
import { render } from "testHelpers/renderHelpers"
import { server } from "testHelpers/server"
import { LicenseBanner } from "./LicenseBanner"
import { Language } from "./LicenseBannerView"
describe("LicenseBanner", () => {
it("does not show when there are no warnings", async () => {
render(<LicenseBanner />)
const bannerPillSingular = await screen.queryByText(Language.licenseIssue)
const bannerPillPlural = await screen.queryByText(Language.licenseIssues(2))
expect(bannerPillSingular).toBe(null)
expect(bannerPillPlural).toBe(null)
})
it("shows when there are warnings", async () => {
server.use(
rest.get("/api/v2/entitlements", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(MockEntitlementsWithWarnings))
}),
)
render(<LicenseBanner />)
const bannerPill = await screen.findByText(Language.licenseIssues(2))
expect(bannerPill).toBeDefined()
})
})
@@ -0,0 +1,21 @@
import { useActor } from "@xstate/react"
import { useContext, useEffect } from "react"
import { XServiceContext } from "xServices/StateContext"
import { LicenseBannerView } from "./LicenseBannerView"
export const LicenseBanner: React.FC = () => {
const xServices = useContext(XServiceContext)
const [entitlementsState, entitlementsSend] = useActor(xServices.entitlementsXService)
const { warnings } = entitlementsState.context.entitlements
/** Gets license data on app mount because LicenseBanner is mounted in App */
useEffect(() => {
entitlementsSend("GET_ENTITLEMENTS")
}, [entitlementsSend])
if (warnings.length) {
return <LicenseBannerView warnings={warnings} />
} else {
return null
}
}
@@ -0,0 +1,22 @@
import { Story } from "@storybook/react"
import { LicenseBannerView, LicenseBannerViewProps } from "./LicenseBannerView"
export default {
title: "components/LicenseBannerView",
component: LicenseBannerView,
}
const Template: Story<LicenseBannerViewProps> = (args) => <LicenseBannerView {...args} />
export const OneWarning = Template.bind({})
OneWarning.args = {
warnings: ["You have exceeded the number of seats in your license."],
}
export const TwoWarnings = Template.bind({})
TwoWarnings.args = {
warnings: [
"You have exceeded the number of seats in your license.",
"You are flying too close to the sun.",
],
}
@@ -0,0 +1,87 @@
import Collapse from "@material-ui/core/Collapse"
import { makeStyles } from "@material-ui/core/styles"
import { Expander } from "components/Expander/Expander"
import { Pill } from "components/Pill/Pill"
import { useState } from "react"
export const Language = {
licenseIssue: "License Issue",
licenseIssues: (num: number): string => `${num} License Issues`,
upgrade: "Contact us to upgrade your license.",
exceeded: "It looks like you've exceeded some limits of your license.",
lessDetails: "Less",
moreDetails: "More",
}
export interface LicenseBannerViewProps {
warnings: string[]
}
export const LicenseBannerView: React.FC<LicenseBannerViewProps> = ({ warnings }) => {
const styles = useStyles()
const [showDetails, setShowDetails] = useState(false)
if (warnings.length === 1) {
return (
<div className={styles.container}>
<Pill text={Language.licenseIssue} type="warning" lightBorder />
<span className={styles.text}>{warnings[0]}</span>
&nbsp;
<a href="mailto:sales@coder.com" className={styles.link}>
{Language.upgrade}
</a>
</div>
)
} else {
return (
<div className={styles.container}>
<div className={styles.flex}>
<div className={styles.leftContent}>
<Pill text={Language.licenseIssues(warnings.length)} type="warning" lightBorder />
<span className={styles.text}>{Language.exceeded}</span>
&nbsp;
<a href="mailto:sales@coder.com" className={styles.link}>
{Language.upgrade}
</a>
</div>
<Expander expanded={showDetails} setExpanded={setShowDetails} />
</div>
<Collapse in={showDetails}>
<ul className={styles.list}>
{warnings.map((warning) => (
<li className={styles.listItem} key={`${warning}`}>
{warning}
</li>
))}
</ul>
</Collapse>
</div>
)
}
}
const useStyles = makeStyles((theme) => ({
container: {
padding: theme.spacing(1.5),
backgroundColor: theme.palette.warning.main,
},
flex: {
display: "flex",
},
leftContent: {
marginRight: theme.spacing(1),
},
text: {
marginLeft: theme.spacing(1),
},
link: {
color: "inherit",
textDecoration: "none",
fontWeight: "bold",
},
list: {
margin: theme.spacing(1.5),
},
listItem: {
margin: theme.spacing(1),
},
}))
+57
View File
@@ -0,0 +1,57 @@
import { Story } from "@storybook/react"
import { Pill, PillProps } from "./Pill"
export default {
title: "components/Pill",
component: Pill,
}
const Template: Story<PillProps> = (args) => <Pill {...args} />
export const Primary = Template.bind({})
Primary.args = {
text: "Primary",
type: "primary",
}
export const Secondary = Template.bind({})
Secondary.args = {
text: "Secondary",
type: "secondary",
}
export const Success = Template.bind({})
Success.args = {
text: "Success",
type: "success",
}
export const Info = Template.bind({})
Info.args = {
text: "Information",
type: "info",
}
export const Warning = Template.bind({})
Warning.args = {
text: "Warning",
type: "warning",
}
export const Error = Template.bind({})
Error.args = {
text: "Error",
type: "error",
}
export const Default = Template.bind({})
Default.args = {
text: "Default",
}
export const WarningLight = Template.bind({})
WarningLight.args = {
text: "Warning",
type: "warning",
lightBorder: true,
}
+68
View File
@@ -0,0 +1,68 @@
import { makeStyles } from "@material-ui/core/styles"
import { FC } from "react"
import { MONOSPACE_FONT_FAMILY } from "theme/constants"
import { PaletteIndex } from "theme/palettes"
import { combineClasses } from "util/combineClasses"
export interface PillProps {
className?: string
icon?: React.ReactNode
text: string
type?: PaletteIndex
lightBorder?: boolean
}
export const Pill: FC<PillProps> = ({ className, icon, text, type, lightBorder = false }) => {
const styles = useStyles({ icon, type, lightBorder })
return (
<div className={combineClasses([styles.wrapper, styles.pillColor, className])} role="status">
{icon && <div className={styles.iconWrapper}>{icon}</div>}
{text}
</div>
)
}
const useStyles = makeStyles((theme) => ({
wrapper: {
fontFamily: MONOSPACE_FONT_FAMILY,
display: "inline-flex",
alignItems: "center",
borderWidth: 1,
borderStyle: "solid",
borderRadius: 99999,
fontSize: 14,
fontWeight: 500,
color: "#FFF",
height: theme.spacing(3),
paddingLeft: ({ icon }: { icon?: React.ReactNode }) =>
icon ? theme.spacing(0.75) : theme.spacing(1.5),
paddingRight: theme.spacing(1.5),
whiteSpace: "nowrap",
},
pillColor: {
backgroundColor: ({ type }: { type?: PaletteIndex }) =>
type ? theme.palette[type].dark : theme.palette.text.secondary,
borderColor: ({ type, lightBorder }: { type?: PaletteIndex; lightBorder?: boolean }) =>
type
? lightBorder
? theme.palette[type].light
: theme.palette[type].main
: theme.palette.text.secondary,
},
iconWrapper: {
marginRight: theme.spacing(0.5),
width: theme.spacing(2),
height: theme.spacing(2),
lineHeight: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
"& > svg": {
width: theme.spacing(2),
height: theme.spacing(2),
},
},
}))
@@ -1,12 +1,11 @@
import CircularProgress from "@material-ui/core/CircularProgress"
import { makeStyles, Theme, useTheme } from "@material-ui/core/styles"
import ErrorIcon from "@material-ui/icons/ErrorOutline"
import StopIcon from "@material-ui/icons/PauseOutlined"
import PlayIcon from "@material-ui/icons/PlayArrowOutlined"
import { WorkspaceBuild } from "api/typesGenerated"
import { Pill } from "components/Pill/Pill"
import React from "react"
import { MONOSPACE_FONT_FAMILY } from "theme/constants"
import { combineClasses } from "util/combineClasses"
import { PaletteIndex } from "theme/palettes"
import { getWorkspaceStatus } from "util/workspace"
const StatusLanguage = {
@@ -28,11 +27,9 @@ const LoadingIcon: React.FC = () => {
}
export const getStatus = (
theme: Theme,
build: WorkspaceBuild,
): {
borderColor: string
backgroundColor: string
type?: PaletteIndex
text: string
icon: React.ReactNode
} => {
@@ -40,78 +37,66 @@ export const getStatus = (
switch (status) {
case undefined:
return {
borderColor: theme.palette.text.secondary,
backgroundColor: theme.palette.text.secondary,
text: StatusLanguage.loading,
icon: <LoadingIcon />,
}
case "started":
return {
borderColor: theme.palette.success.main,
backgroundColor: theme.palette.success.dark,
type: "success",
text: StatusLanguage.started,
icon: <PlayIcon />,
}
case "starting":
return {
borderColor: theme.palette.success.main,
backgroundColor: theme.palette.success.dark,
type: "success",
text: StatusLanguage.starting,
icon: <LoadingIcon />,
}
case "stopping":
return {
borderColor: theme.palette.warning.main,
backgroundColor: theme.palette.warning.dark,
type: "warning",
text: StatusLanguage.stopping,
icon: <LoadingIcon />,
}
case "stopped":
return {
borderColor: theme.palette.warning.main,
backgroundColor: theme.palette.warning.dark,
type: "warning",
text: StatusLanguage.stopped,
icon: <StopIcon />,
}
case "deleting":
return {
borderColor: theme.palette.warning.main,
backgroundColor: theme.palette.warning.dark,
type: "warning",
text: StatusLanguage.deleting,
icon: <LoadingIcon />,
}
case "deleted":
return {
borderColor: theme.palette.error.main,
backgroundColor: theme.palette.error.dark,
type: "error",
text: StatusLanguage.deleted,
icon: <ErrorIcon />,
}
case "canceling":
return {
borderColor: theme.palette.warning.main,
backgroundColor: theme.palette.warning.dark,
type: "warning",
text: StatusLanguage.canceling,
icon: <LoadingIcon />,
}
case "canceled":
return {
borderColor: theme.palette.warning.main,
backgroundColor: theme.palette.warning.dark,
type: "warning",
text: StatusLanguage.canceled,
icon: <ErrorIcon />,
}
case "error":
return {
borderColor: theme.palette.error.main,
backgroundColor: theme.palette.error.dark,
type: "error",
text: StatusLanguage.failed,
icon: <ErrorIcon />,
}
case "queued":
return {
borderColor: theme.palette.info.main,
backgroundColor: theme.palette.info.dark,
type: "info",
text: StatusLanguage.queued,
icon: <LoadingIcon />,
}
@@ -128,50 +113,6 @@ export const WorkspaceStatusBadge: React.FC<React.PropsWithChildren<WorkspaceSta
build,
className,
}) => {
const styles = useStyles()
const theme = useTheme()
const { text, icon, ...colorStyles } = getStatus(theme, build)
return (
<div
className={combineClasses([styles.wrapper, className])}
style={{ ...colorStyles }}
role="status"
>
<div className={styles.iconWrapper}>{icon}</div>
{text}
</div>
)
const { text, icon, type } = getStatus(build)
return <Pill className={className} icon={icon} text={text} type={type} />
}
const useStyles = makeStyles((theme) => ({
wrapper: {
fontFamily: MONOSPACE_FONT_FAMILY,
display: "inline-flex",
alignItems: "center",
borderWidth: 1,
borderStyle: "solid",
borderRadius: 99999,
fontSize: 14,
fontWeight: 500,
color: "#FFF",
height: theme.spacing(3),
paddingLeft: theme.spacing(0.75),
paddingRight: theme.spacing(1.5),
whiteSpace: "nowrap",
},
iconWrapper: {
marginRight: theme.spacing(0.5),
width: theme.spacing(2),
height: theme.spacing(2),
lineHeight: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
"& > svg": {
width: theme.spacing(2),
height: theme.spacing(2),
},
},
}))
+19
View File
@@ -636,3 +636,22 @@ export const makeMockApiError = ({
},
isAxiosError: true,
})
export const MockEntitlements: TypesGen.Entitlements = {
warnings: [],
has_license: false,
features: {},
}
export const MockEntitlementsWithWarnings: TypesGen.Entitlements = {
warnings: ["You are over your active user limit.", "And another thing."],
has_license: true,
features: {
activeUsers: {
enabled: true,
entitlement: "entitled",
limit: 100,
actual: 102,
},
},
}
+3
View File
@@ -144,4 +144,7 @@ export const handlers = [
rest.get("/api/v2/workspacebuilds/:workspaceBuildId/logs", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockWorkspaceBuildLogs))
}),
rest.get("/api/v2/entitlements", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockEntitlements))
}),
]
+4
View File
@@ -1,6 +1,9 @@
import { PaletteOptions } from "@material-ui/core/styles/createPalette"
import { colors } from "./colors"
// Couldn't find a type for this so I made one. We can extend the palette if needed with module augmentation.
export type PaletteIndex = "primary" | "secondary" | "info" | "success" | "error" | "warning"
export const darkPalette: PaletteOptions = {
type: "dark",
primary: {
@@ -24,6 +27,7 @@ export const darkPalette: PaletteOptions = {
},
divider: colors.gray[13],
warning: {
light: colors.orange[7],
main: colors.orange[11],
dark: colors.orange[15],
},
+3
View File
@@ -4,12 +4,14 @@ import { useNavigate } from "react-router"
import { ActorRefFrom } from "xstate"
import { authMachine } from "./auth/authXService"
import { buildInfoMachine } from "./buildInfo/buildInfoXService"
import { entitlementsMachine } from "./entitlements/entitlementsXService"
import { siteRolesMachine } from "./roles/siteRolesXService"
import { usersMachine } from "./users/usersXService"
interface XServiceContextType {
authXService: ActorRefFrom<typeof authMachine>
buildInfoXService: ActorRefFrom<typeof buildInfoMachine>
entitlementsXService: ActorRefFrom<typeof entitlementsMachine>
usersXService: ActorRefFrom<typeof usersMachine>
siteRolesXService: ActorRefFrom<typeof siteRolesMachine>
}
@@ -40,6 +42,7 @@ export const XServiceProvider: FC<{ children: ReactNode }> = ({ children }) => {
authMachine.withConfig({ actions: { redirectToSetupPage } }),
),
buildInfoXService: useInterpret(buildInfoMachine),
entitlementsXService: useInterpret(entitlementsMachine),
usersXService: useInterpret(() =>
usersMachine.withConfig({ actions: { redirectToUsersPage } }),
),
@@ -0,0 +1,92 @@
import { MockEntitlementsWithWarnings } from "testHelpers/entities"
import { assign, createMachine } from "xstate"
import * as API from "../../api/api"
import { Entitlements } from "../../api/typesGenerated"
export const Language = {
getEntitlementsError: "Error getting license entitlements.",
}
export type EntitlementsContext = {
entitlements: Entitlements
getEntitlementsError?: Error | unknown
}
export type EntitlementsEvent =
| {
type: "GET_ENTITLEMENTS"
}
| { type: "SHOW_MOCK_BANNER" }
| { type: "HIDE_MOCK_BANNER" }
const emptyEntitlements = {
warnings: [],
features: {},
has_license: false,
}
export const entitlementsMachine = createMachine(
{
id: "entitlementsMachine",
initial: "idle",
schema: {
context: {} as EntitlementsContext,
events: {} as EntitlementsEvent,
services: {
getEntitlements: {
data: {} as Entitlements,
},
},
},
tsTypes: {} as import("./entitlementsXService.typegen").Typegen0,
context: {
entitlements: emptyEntitlements,
},
states: {
idle: {
on: {
GET_ENTITLEMENTS: "gettingEntitlements",
SHOW_MOCK_BANNER: { actions: "assignMockEntitlements" },
HIDE_MOCK_BANNER: { actions: "clearMockEntitlements" },
},
},
gettingEntitlements: {
entry: "clearGetEntitlementsError",
invoke: {
id: "getEntitlements",
src: "getEntitlements",
onDone: {
target: "idle",
actions: ["assignEntitlements"],
},
onError: {
target: "idle",
actions: ["assignGetEntitlementsError"],
},
},
},
},
},
{
actions: {
assignEntitlements: assign({
entitlements: (_, event) => event.data,
}),
assignGetEntitlementsError: assign({
getEntitlementsError: (_, event) => event.data,
}),
clearGetEntitlementsError: assign({
getEntitlementsError: (_) => undefined,
}),
assignMockEntitlements: assign({
entitlements: (_) => MockEntitlementsWithWarnings,
}),
clearMockEntitlements: assign({
entitlements: (_) => emptyEntitlements,
}),
},
services: {
getEntitlements: () => API.getEntitlements(),
},
},
)