mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
co-authored by
Kira Pilot
parent
f7ccfa2ab9
commit
49de44c76d
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
<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>
|
||||
|
||||
<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),
|
||||
},
|
||||
}))
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user