feat: enable enterprise users to specify a custom logo (#5566)

* feat: enable enterprise users to specify a custom logo

This adds a field in deployment settings that allows users to specify
the URL to a custom logo that will display in the dashboard.

This also groups service banner into a new appearance settings page.
It adds a Fieldset component to allow for modular fields moving forward.

* Fix tests
This commit is contained in:
Kyle Carberry
2023-01-04 15:31:45 -06:00
committed by GitHub
parent 175be621cf
commit 0dba2defd1
35 changed files with 824 additions and 597 deletions
+4 -4
View File
@@ -76,8 +76,8 @@ const GeneralSettingsPage = lazy(
const SecuritySettingsPage = lazy(
() => import("./pages/DeploySettingsPage/SecuritySettingsPage"),
)
const ServiceBannerSettingsPage = lazy(
() => import("./pages/DeploySettingsPage/ServiceBannerSettingsPage"),
const AppearanceSettingsPage = lazy(
() => import("./pages/DeploySettingsPage/AppearanceSettingsPage"),
)
const UserAuthSettingsPage = lazy(
() => import("./pages/DeploySettingsPage/UserAuthSettingsPage"),
@@ -345,14 +345,14 @@ export const AppRouter: FC = () => {
}
/>
<Route
path="service-banner"
path="appearance"
element={
<AuthAndFrame>
<RequirePermission
isFeatureVisible={Boolean(permissions?.viewDeploymentConfig)}
>
<DeploySettingsLayout>
<ServiceBannerSettingsPage />
<AppearanceSettingsPage />
</DeploySettingsLayout>
</RequirePermission>
</AuthAndFrame>
+6 -6
View File
@@ -723,15 +723,15 @@ export const getFile = async (fileId: string): Promise<ArrayBuffer> => {
return response.data
}
export const getServiceBanner = async (): Promise<TypesGen.ServiceBanner> => {
const response = await axios.get(`/api/v2/service-banner`)
export const getAppearance = async (): Promise<TypesGen.AppearanceConfig> => {
const response = await axios.get(`/api/v2/appearance`)
return response.data
}
export const setServiceBanner = async (
b: TypesGen.ServiceBanner,
): Promise<TypesGen.ServiceBanner> => {
const response = await axios.put(`/api/v2/service-banner`, b)
export const updateAppearance = async (
b: TypesGen.AppearanceConfig,
): Promise<TypesGen.AppearanceConfig> => {
const response = await axios.put(`/api/v2/appearance`, b)
return response.data
}
+1 -1
View File
@@ -23,5 +23,5 @@ export enum FeatureNames {
SCIM = "scim",
TemplateRBAC = "template_rbac",
HighAvailability = "high_availability",
ServiceBanners = "service_banners",
Appearance = "appearance",
}
+13 -2
View File
@@ -31,6 +31,12 @@ export interface AgentStatsReportResponse {
readonly tx_bytes: number
}
// From codersdk/appearance.go
export interface AppearanceConfig {
readonly logo_url: string
readonly service_banner: ServiceBannerConfig
}
// From codersdk/roles.go
export interface AssignableRoles extends Role {
readonly assignable: boolean
@@ -611,8 +617,8 @@ export interface ServerSentEvent {
readonly data: any
}
// From codersdk/servicebanner.go
export interface ServiceBanner {
// From codersdk/appearance.go
export interface ServiceBannerConfig {
readonly enabled: boolean
readonly message?: string
readonly background_color?: string
@@ -739,6 +745,11 @@ export interface UpdateActiveTemplateVersion {
readonly id: string
}
// From codersdk/branding.go
export interface UpdateBrandingRequest {
readonly logo_url: string
}
// From codersdk/updatecheck.go
export interface UpdateCheckResponse {
readonly current: boolean
@@ -0,0 +1,66 @@
import { makeStyles } from "@material-ui/core/styles"
import React from "react"
import Button from "@material-ui/core/Button"
export const Fieldset: React.FC<{
children: React.ReactNode
title: string | JSX.Element
validation?: string | JSX.Element | false
button?: JSX.Element | false
onSubmit: React.FormEventHandler<HTMLFormElement>
isSubmitting?: boolean
}> = ({ title, children, validation, button, onSubmit, isSubmitting }) => {
const styles = useStyles()
return (
<form className={styles.fieldset} onSubmit={onSubmit}>
<header className={styles.header}>
<div className={styles.title}>{title}</div>
<div className={styles.body}>{children}</div>
</header>
<footer className={styles.footer}>
<div className={styles.validation}>{validation}</div>
{button || (
<Button type="submit" disabled={isSubmitting}>
Submit
</Button>
)}
</footer>
</form>
)
}
const useStyles = makeStyles((theme) => ({
fieldset: {
borderRadius: theme.spacing(1),
border: `1px solid ${theme.palette.divider}`,
background: theme.palette.background.paper,
marginTop: theme.spacing(4),
},
title: {
...theme.typography.h5,
fontWeight: "bold",
},
body: {
...theme.typography.body2,
paddingTop: theme.spacing(2),
"& p": {
marginTop: 0,
marginBottom: theme.spacing(2),
},
},
validation: {
color: theme.palette.text.secondary,
},
header: {
padding: theme.spacing(3),
},
footer: {
background: theme.palette.background.paperLight,
padding: `${theme.spacing(2)}px ${theme.spacing(3)}px`,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
},
}))
@@ -1,21 +1,14 @@
import { makeStyles } from "@material-ui/core/styles"
import Brush from "@material-ui/icons/Brush"
import LaunchOutlined from "@material-ui/icons/LaunchOutlined"
import LockRounded from "@material-ui/icons/LockRounded"
import Globe from "@material-ui/icons/Public"
import VpnKeyOutlined from "@material-ui/icons/VpnKeyOutlined"
import Info from "@material-ui/icons/Info"
import { useSelector } from "@xstate/react"
import { GitIcon } from "components/Icons/GitIcon"
import { Stack } from "components/Stack/Stack"
import React, {
ElementType,
PropsWithChildren,
ReactNode,
useContext,
} from "react"
import React, { ElementType, PropsWithChildren, ReactNode } from "react"
import { NavLink } from "react-router-dom"
import { combineClasses } from "util/combineClasses"
import { XServiceContext } from "../../xServices/StateContext"
const SidebarNavItem: React.FC<
PropsWithChildren<{ href: string; icon: ReactNode }>
@@ -48,11 +41,6 @@ const SidebarNavItemIcon: React.FC<{ icon: ElementType }> = ({
export const Sidebar: React.FC = () => {
const styles = useStyles()
const xServices = useContext(XServiceContext)
const experimental = useSelector(
xServices.entitlementsXService,
(state) => state.context.entitlements.experimental,
)
return (
<nav className={styles.sidebar}>
@@ -62,20 +50,24 @@ export const Sidebar: React.FC = () => {
>
General
</SidebarNavItem>
<SidebarNavItem
href="../appearance"
icon={<SidebarNavItemIcon icon={Brush} />}
>
Appearance
</SidebarNavItem>
<SidebarNavItem
href="../userauth"
icon={<SidebarNavItemIcon icon={VpnKeyOutlined} />}
>
User Authentication
</SidebarNavItem>
{experimental && (
<SidebarNavItem
href="../gitauth"
icon={<SidebarNavItemIcon icon={GitIcon} />}
>
Git Authentication
</SidebarNavItem>
)}
<SidebarNavItem
href="../gitauth"
icon={<SidebarNavItemIcon icon={GitIcon} />}
>
Git Authentication
</SidebarNavItem>
<SidebarNavItem
href="../network"
icon={<SidebarNavItemIcon icon={Globe} />}
@@ -88,12 +80,6 @@ export const Sidebar: React.FC = () => {
>
Security
</SidebarNavItem>
<SidebarNavItem
href="../service-banner"
icon={<SidebarNavItemIcon icon={Info} />}
>
Service Banner
</SidebarNavItem>
</nav>
)
}
+2
View File
@@ -7,6 +7,7 @@ import { NavbarView } from "../NavbarView/NavbarView"
export const Navbar: React.FC = () => {
const xServices = useContext(XServiceContext)
const [appearanceState] = useActor(xServices.appearanceXService)
const [authState, authSend] = useActor(xServices.authXService)
const [buildInfoState] = useActor(xServices.buildInfoXService)
const { me, permissions } = authState.context
@@ -24,6 +25,7 @@ export const Navbar: React.FC = () => {
return (
<NavbarView
user={me}
logo_url={appearanceState.context.appearance.logo_url}
buildInfo={buildInfoState.context.buildInfo}
onSignOut={onSignOut}
canViewAuditLog={canViewAuditLog}
+15 -3
View File
@@ -14,6 +14,7 @@ import { Logo } from "../Icons/Logo"
import { UserDropdown } from "../UserDropdown/UsersDropdown"
export interface NavbarViewProps {
logo_url?: string
user?: TypesGen.User
buildInfo?: TypesGen.BuildInfoResponse
onSignOut: () => void
@@ -84,6 +85,7 @@ const NavItems: React.FC<
}
export const NavbarView: React.FC<React.PropsWithChildren<NavbarViewProps>> = ({
user,
logo_url,
buildInfo,
onSignOut,
canViewAuditLog,
@@ -112,7 +114,11 @@ export const NavbarView: React.FC<React.PropsWithChildren<NavbarViewProps>> = ({
>
<div className={styles.drawer}>
<div className={styles.drawerHeader}>
<Logo fill="white" opacity={1} width={125} />
{logo_url ? (
<img src={logo_url} alt="Custom Logo" />
) : (
<Logo fill="white" opacity={1} width={125} />
)}
</div>
<NavItems
canViewAuditLog={canViewAuditLog}
@@ -122,7 +128,11 @@ export const NavbarView: React.FC<React.PropsWithChildren<NavbarViewProps>> = ({
</Drawer>
<NavLink className={styles.logo} to="/workspaces">
<Logo fill="white" opacity={1} width={125} />
{logo_url ? (
<img src={logo_url} alt="Custom Logo" />
) : (
<Logo fill="white" opacity={1} width={125} />
)}
</NavLink>
<NavItems
@@ -199,8 +209,10 @@ const useStyles = makeStyles((theme) => ({
display: "flex",
height: navHeight,
paddingRight: theme.spacing(4),
"& svg": {
// svg is for the Coder logo, img is for custom images
"& svg, & img": {
width: 109,
maxHeight: `calc(${navHeight}px)`,
},
},
title: {
@@ -1,20 +1,14 @@
import { useActor } from "@xstate/react"
import { useContext, useEffect } from "react"
import { useContext } from "react"
import { XServiceContext } from "xServices/StateContext"
import { ServiceBannerView } from "./ServiceBannerView"
export const ServiceBanner: React.FC = () => {
const xServices = useContext(XServiceContext)
const [serviceBannerState, serviceBannerSend] = useActor(
xServices.serviceBannerXService,
)
const [appearanceState] = useActor(xServices.appearanceXService)
const { message, background_color, enabled } =
serviceBannerState.context.serviceBanner
useEffect(() => {
serviceBannerSend("GET_BANNER")
}, [serviceBannerSend])
appearanceState.context.appearance.service_banner
if (!enabled) {
return null
@@ -25,7 +19,7 @@ export const ServiceBanner: React.FC = () => {
<ServiceBannerView
message={message}
backgroundColor={background_color}
preview={serviceBannerState.context.preview}
preview={appearanceState.context.preview}
/>
)
} else {
+2 -2
View File
@@ -13,7 +13,7 @@ import templateVersionPage from "./templateVersionPage.json"
import loginPage from "./loginPage.json"
import workspaceChangeVersionPage from "./workspaceChangeVersionPage.json"
import workspaceSchedulePage from "./workspaceSchedulePage.json"
import serviceBannerSettings from "./serviceBannerSettings.json"
import appearanceSettings from "./appearanceSettings.json"
import starterTemplatesPage from "./starterTemplatesPage.json"
import starterTemplatePage from "./starterTemplatePage.json"
import createTemplatePage from "./createTemplatePage.json"
@@ -34,7 +34,7 @@ export const en = {
loginPage,
workspaceChangeVersionPage,
workspaceSchedulePage,
serviceBannerSettings,
appearanceSettings,
starterTemplatesPage,
starterTemplatePage,
createTemplatePage,
@@ -0,0 +1,278 @@
import Button from "@material-ui/core/Button"
import FormControlLabel from "@material-ui/core/FormControlLabel"
import FormHelperText from "@material-ui/core/FormHelperText"
import InputAdornment from "@material-ui/core/InputAdornment"
import { useTheme } from "@material-ui/core/styles"
import makeStyles from "@material-ui/core/styles/makeStyles"
import Switch from "@material-ui/core/Switch"
import TextField from "@material-ui/core/TextField"
import { useActor } from "@xstate/react"
import { FeatureNames } from "api/types"
import { AppearanceConfig } from "api/typesGenerated"
import {
Badges,
DisabledBadge,
EnterpriseBadge,
EntitledBadge,
} from "components/DeploySettingsLayout/Badges"
import { Fieldset } from "components/DeploySettingsLayout/Fieldset"
import { Header } from "components/DeploySettingsLayout/Header"
import { Stack } from "components/Stack/Stack"
import { useFormik } from "formik"
import React, { useContext, useState } from "react"
import { BlockPicker } from "react-color"
import { Helmet } from "react-helmet-async"
import { useTranslation } from "react-i18next"
import { getFormHelpers } from "util/formUtils"
import { pageTitle } from "util/page"
import { XServiceContext } from "xServices/StateContext"
// ServiceBanner is unlike the other Deployment Settings pages because it
// implements a form, whereas the others are read-only. We make this
// exception because the Service Banner is visual, and configuring it from
// the command line would be a significantly worse user experience.
const AppearanceSettingsPage: React.FC = () => {
const xServices = useContext(XServiceContext)
const [appearanceXService, appearanceSend] = useActor(
xServices.appearanceXService,
)
const [entitlementsState] = useActor(xServices.entitlementsXService)
const appearance = appearanceXService.context.appearance
const styles = useStyles()
const isEntitled =
entitlementsState.context.entitlements.features[FeatureNames.Appearance]
.entitlement !== "not_entitled"
const updateAppearance = (
newConfig: Partial<AppearanceConfig>,
preview: boolean,
) => {
const newAppearance = {
...appearance,
...newConfig,
}
if (preview) {
appearanceSend({
type: "SET_PREVIEW_APPEARANCE",
appearance: newAppearance,
})
return
}
appearanceSend({
type: "SET_APPEARANCE",
appearance: newAppearance,
})
}
const logoForm = useFormik<{
logo_url: string
}>({
initialValues: {
logo_url: appearance.logo_url,
},
onSubmit: (values) => updateAppearance(values, false),
})
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,
},
onSubmit: (values) =>
updateAppearance(
{
service_banner: values,
},
false,
),
})
const serviceBannerFieldHelpers = getFormHelpers(serviceBannerForm)
const [backgroundColor, setBackgroundColor] = useState(
serviceBannerForm.values.background_color,
)
const theme = useTheme()
const [t] = useTranslation("appearanceSettings")
return (
<>
<Helmet>
<title>{pageTitle("Appearance Settings")}</title>
</Helmet>
<Header
title="Appearance"
description="Customize the look and feel of your Coder deployment."
/>
<Badges>
{isEntitled ? <EntitledBadge /> : <DisabledBadge />}
<EnterpriseBadge />
</Badges>
<Fieldset
title="Logo URL"
validation="We recommend a transparent image with 3:1 aspect ratio."
onSubmit={logoForm.handleSubmit}
>
<p>
Specify a custom URL for your logo to be displayed in the top left
corner of the dashboard.
</p>
<TextField
{...logoFieldHelpers("logo_url")}
defaultValue={appearance.logo_url}
fullWidth
placeholder="Leave empty to display the Coder logo."
InputProps={{
endAdornment: (
<InputAdornment position="end" className={styles.logoAdornment}>
<img
alt=""
src={logoForm.values.logo_url}
// This prevent browser to display the ugly error icon if the
// image path is wrong or user didn't finish typing the url
onError={(e) => (e.currentTarget.style.display = "none")}
onLoad={(e) => (e.currentTarget.style.display = "inline")}
/>
</InputAdornment>
),
}}
/>
</Fieldset>
<Fieldset
title="Service Banner"
onSubmit={serviceBannerForm.handleSubmit}
button={
!isEntitled && (
<Button
onClick={() => {
updateAppearance(
{
service_banner: {
message:
"👋 **This** is a service banner. The banner's color and text are editable.",
background_color: "#004852",
enabled: true,
},
},
true,
)
}}
>
{t("showPreviewLabel")}
</Button>
)
}
validation={
!isEntitled && (
<p>
Your license does not include Service Banners.{" "}
<a href="mailto:sales@coder.com">Contact sales</a> to learn more.
</p>
)
}
>
<p>Configure a banner that displays a message to all users.</p>
{isEntitled && (
<Stack>
<FormControlLabel
control={
<Switch
color="primary"
checked={serviceBannerForm.values.enabled}
onChange={async () => {
const newState = !serviceBannerForm.values.enabled
const newBanner = {
...serviceBannerForm.values,
enabled: newState,
}
updateAppearance(
{
service_banner: newBanner,
},
false,
)
await serviceBannerForm.setFieldValue("enabled", newState)
}}
/>
}
label="Enabled"
/>
<Stack spacing={0}>
<TextField
{...serviceBannerFieldHelpers("message")}
fullWidth
label="Message"
variant="outlined"
multiline
/>
<FormHelperText>{t("messageHelperText")}</FormHelperText>
</Stack>
<Stack spacing={0}>
<h3>{"Background Color"}</h3>
<BlockPicker
color={backgroundColor}
onChange={async (color) => {
setBackgroundColor(color.hex)
await serviceBannerForm.setFieldValue(
"background_color",
color.hex,
)
updateAppearance(
{
service_banner: {
...serviceBannerForm.values,
background_color: color.hex,
},
},
true,
)
}}
triangle="hide"
colors={["#004852", "#D65D0F", "#4CD473", "#D94A5D", "#5A00CF"]}
styles={{
default: {
input: {
color: "white",
backgroundColor: theme.palette.background.default,
},
body: {
backgroundColor: "black",
color: "white",
},
card: {
backgroundColor: "black",
},
},
}}
/>
</Stack>
</Stack>
)}
</Fieldset>
</>
)
}
const useStyles = makeStyles((theme) => ({
form: {
maxWidth: "500px",
},
logoAdornment: {
width: theme.spacing(3),
height: theme.spacing(3),
"& img": {
maxWidth: "100%",
},
},
}))
export default AppearanceSettingsPage
@@ -1,231 +0,0 @@
import TextField from "@material-ui/core/TextField"
import { useActor } from "@xstate/react"
import { FeatureNames } from "api/types"
import {
Badges,
DisabledBadge,
EnterpriseBadge,
EntitledBadge,
} from "components/DeploySettingsLayout/Badges"
import { Header } from "components/DeploySettingsLayout/Header"
import { LoadingButton } from "components/LoadingButton/LoadingButton"
import { Stack } from "components/Stack/Stack"
import { FormikContextType, useFormik } from "formik"
import React, { useContext, useState } from "react"
import { Helmet } from "react-helmet-async"
import { pageTitle } from "util/page"
import * as Yup from "yup"
import { XServiceContext } from "xServices/StateContext"
import { getFormHelpers } from "util/formUtils"
import makeStyles from "@material-ui/core/styles/makeStyles"
import FormControlLabel from "@material-ui/core/FormControlLabel"
import Switch from "@material-ui/core/Switch"
import { BlockPicker } from "react-color"
import { useTheme } from "@material-ui/core/styles"
import FormHelperText from "@material-ui/core/FormHelperText"
import Button from "@material-ui/core/Button"
import { useTranslation } from "react-i18next"
export interface ServiceBannerFormValues {
message?: string
backgroundColor?: string
enabled: boolean
}
// TODO:
const validationSchema = Yup.object({})
// ServiceBanner is unlike the other Deployment Settings pages because it
// implements a form, whereas the others are read-only. We make this
// exception because the Service Banner is visual, and configuring it from
// the command line would be a significantly worse user experience.
const ServiceBannerSettingsPage: React.FC = () => {
const xServices = useContext(XServiceContext)
const [serviceBannerState, serviceBannerSend] = useActor(
xServices.serviceBannerXService,
)
const [entitlementsState] = useActor(xServices.entitlementsXService)
const serviceBanner = serviceBannerState.context.serviceBanner
const styles = useStyles()
const isEntitled =
entitlementsState.context.entitlements.features[FeatureNames.ServiceBanners]
.entitlement !== "not_entitled"
const setBanner = (values: ServiceBannerFormValues, preview: boolean) => {
const newBanner = {
message: values.message,
enabled: values.enabled,
background_color: values.backgroundColor,
}
if (preview) {
serviceBannerSend({
type: "SET_PREVIEW_BANNER",
serviceBanner: newBanner,
})
return
}
serviceBannerSend({
type: "SET_BANNER",
serviceBanner: newBanner,
})
}
const initialValues: ServiceBannerFormValues = {
message: serviceBanner.message,
enabled: serviceBanner.enabled,
backgroundColor: serviceBanner.background_color,
}
const form: FormikContextType<ServiceBannerFormValues> =
useFormik<ServiceBannerFormValues>({
initialValues,
validationSchema,
onSubmit: (values) => setBanner(values, false),
})
const getFieldHelpers = getFormHelpers<ServiceBannerFormValues>(form)
const [backgroundColor, setBackgroundColor] = useState(
form.values.backgroundColor,
)
const theme = useTheme()
const [t] = useTranslation("serviceBannerSettings")
return (
<>
<Helmet>
<title>{pageTitle("Service Banner Settings")}</title>
</Helmet>
<Header
title="Service Banner"
description="Configure a banner that displays a message to all users"
docsHref="https://coder.com/docs/coder-oss/latest/admin/service-banners"
/>
<Badges>
{isEntitled ? <EntitledBadge /> : <DisabledBadge />}
<EnterpriseBadge />
</Badges>
{isEntitled ? (
<form className={styles.form} onSubmit={form.handleSubmit}>
<Stack>
<FormControlLabel
control={
<Switch
color="primary"
checked={form.values.enabled}
onChange={() => {
const newState = !form.values.enabled
const newBanner = {
...form.values,
enabled: newState,
}
setBanner(newBanner, false)
form.setFieldValue("enabled", newState)
}}
/>
}
label="Enabled"
/>
<Stack spacing={0}>
<TextField
{...getFieldHelpers("message")}
fullWidth
label="Message"
variant="outlined"
multiline
onChange={(e) => {
form.setFieldValue("message", e.target.value)
setBanner(
{
...form.values,
message: e.target.value,
},
true,
)
}}
/>
<FormHelperText>{t("messageHelperText")}</FormHelperText>
</Stack>
<Stack spacing={0}>
<h3>{"Background Color"}</h3>
<BlockPicker
color={backgroundColor}
onChange={(color) => {
setBackgroundColor(color.hex)
form.setFieldValue("backgroundColor", color.hex)
setBanner(
{
...form.values,
backgroundColor: color.hex,
},
true,
)
}}
triangle="hide"
colors={["#004852", "#D65D0F", "#4CD473", "#D94A5D", "#5A00CF"]}
styles={{
default: {
input: {
color: "white",
backgroundColor: theme.palette.background.default,
},
body: {
backgroundColor: "black",
color: "white",
},
card: {
backgroundColor: "black",
},
},
}}
/>
</Stack>
<Stack direction="row">
<LoadingButton loading={false} type="submit" variant="contained">
{t("updateLabel")}
</LoadingButton>
</Stack>
</Stack>
</form>
) : (
<>
<p>
Your license does not include Service Banners.{" "}
<a href="mailto:sales@coder.com">Contact sales</a> to learn more.
</p>
<Button
onClick={() => {
setBanner(
{
message:
"👋 **This** is a service banner. The banner's color and text are editable.",
backgroundColor: "#004852",
enabled: true,
},
true,
)
}}
>
{t("showPreviewLabel")}
</Button>
</>
)}
</>
)
}
const useStyles = makeStyles(() => ({
form: {
maxWidth: "500px",
},
}))
export default ServiceBannerSettingsPage
+7
View File
@@ -1097,3 +1097,10 @@ export const MockPermissions: Permissions = {
viewAuditLog: true,
viewDeploymentConfig: true,
}
export const MockAppearance: TypesGen.AppearanceConfig = {
logo_url: "",
service_banner: {
enabled: false,
},
}
+4
View File
@@ -282,4 +282,8 @@ export const handlers = [
rest.get("/api/v2/workspace-quota/:userId", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(MockWorkspaceQuota))
}),
rest.get("/api/v2/appearance", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockAppearance))
}),
]
+3 -3
View File
@@ -7,13 +7,13 @@ import { updateCheckMachine } from "./updateCheck/updateCheckXService"
import { deploymentConfigMachine } from "./deploymentConfig/deploymentConfigMachine"
import { entitlementsMachine } from "./entitlements/entitlementsXService"
import { siteRolesMachine } from "./roles/siteRolesXService"
import { serviceBannerMachine } from "./serviceBanner/serviceBannerXService"
import { appearanceMachine } from "./appearance/appearanceXService"
interface XServiceContextType {
authXService: ActorRefFrom<typeof authMachine>
buildInfoXService: ActorRefFrom<typeof buildInfoMachine>
entitlementsXService: ActorRefFrom<typeof entitlementsMachine>
serviceBannerXService: ActorRefFrom<typeof serviceBannerMachine>
appearanceXService: ActorRefFrom<typeof appearanceMachine>
siteRolesXService: ActorRefFrom<typeof siteRolesMachine>
// Since the info here is used by multiple deployment settings page and we don't want to refetch them every time
deploymentConfigXService: ActorRefFrom<typeof deploymentConfigMachine>
@@ -37,7 +37,7 @@ export const XServiceProvider: FC<{ children: ReactNode }> = ({ children }) => {
authXService: useInterpret(authMachine),
buildInfoXService: useInterpret(buildInfoMachine),
entitlementsXService: useInterpret(entitlementsMachine),
serviceBannerXService: useInterpret(serviceBannerMachine),
appearanceXService: useInterpret(appearanceMachine),
siteRolesXService: useInterpret(siteRolesMachine),
deploymentConfigXService: useInterpret(deploymentConfigMachine),
updateCheckXService: useInterpret(updateCheckMachine),
@@ -0,0 +1,137 @@
import { displaySuccess } from "components/GlobalSnackbar/utils"
import { assign, createMachine } from "xstate"
import * as API from "../../api/api"
import { AppearanceConfig } from "../../api/typesGenerated"
export const Language = {
getAppearanceError: "Error getting appearance.",
setAppearanceError: "Error setting appearance.",
}
export type AppearanceContext = {
appearance: AppearanceConfig
getAppearanceError?: Error | unknown
setAppearanceError?: Error | unknown
preview: boolean
}
export type AppearanceEvent =
| {
type: "GET_APPEARANCE"
}
| { type: "SET_PREVIEW_APPEARANCE"; appearance: AppearanceConfig }
| { type: "SET_APPEARANCE"; appearance: AppearanceConfig }
const emptyAppearance: AppearanceConfig = {
logo_url: "",
service_banner: {
enabled: false,
},
}
export const appearanceMachine = createMachine(
{
id: "appearanceMachine",
predictableActionArguments: true,
tsTypes: {} as import("./appearanceXService.typegen").Typegen0,
schema: {
context: {} as AppearanceContext,
events: {} as AppearanceEvent,
services: {
getAppearance: {
data: {} as AppearanceConfig,
},
setAppearance: {
data: {},
},
},
},
context: {
appearance: emptyAppearance,
preview: false,
},
initial: "gettingAppearance",
states: {
idle: {
on: {
GET_APPEARANCE: "gettingAppearance",
SET_PREVIEW_APPEARANCE: "settingPreviewAppearance",
SET_APPEARANCE: "settingAppearance",
},
},
gettingAppearance: {
entry: "clearGetAppearanceError",
invoke: {
id: "getAppearance",
src: "getAppearance",
onDone: {
target: "idle",
actions: ["assignAppearance"],
},
onError: {
target: "idle",
actions: ["assignGetAppearanceError"],
},
},
},
settingPreviewAppearance: {
entry: [
"clearGetAppearanceError",
"clearSetAppearanceError",
"assignPreviewAppearance",
],
always: {
target: "idle",
},
},
settingAppearance: {
entry: "clearSetAppearanceError",
invoke: {
id: "setAppearance",
src: "setAppearance",
onDone: {
target: "idle",
actions: ["assignAppearance", "notifyUpdateAppearanceSuccess"],
},
onError: {
target: "idle",
actions: ["assignSetAppearanceError"],
},
},
},
},
},
{
actions: {
assignPreviewAppearance: assign({
appearance: (_, event) => event.appearance,
// The xState docs suggest that we can use a static value, but I failed
// to find a way to do that that doesn't generate type errors.
preview: (_, __) => true,
}),
notifyUpdateAppearanceSuccess: () => {
displaySuccess("Successfully updated appearance settings!")
},
assignAppearance: assign({
appearance: (_, event) => event.data as AppearanceConfig,
preview: (_, __) => false,
}),
assignGetAppearanceError: assign({
getAppearanceError: (_, event) => event.data,
}),
clearGetAppearanceError: assign({
getAppearanceError: (_) => undefined,
}),
assignSetAppearanceError: assign({
setAppearanceError: (_, event) => event.data,
}),
clearSetAppearanceError: assign({
setAppearanceError: (_) => undefined,
}),
},
services: {
getAppearance: API.getAppearance,
setAppearance: (_, event) => API.updateAppearance(event.appearance),
},
},
)
@@ -1,134 +0,0 @@
import { displaySuccess } from "components/GlobalSnackbar/utils"
import { assign, createMachine } from "xstate"
import * as API from "../../api/api"
import { ServiceBanner } from "../../api/typesGenerated"
export const Language = {
getServiceBannerError: "Error getting service banner.",
setServiceBannerError: "Error setting service banner.",
}
export type ServiceBannerContext = {
serviceBanner: ServiceBanner
getServiceBannerError?: Error | unknown
setServiceBannerError?: Error | unknown
preview: boolean
}
export type ServiceBannerEvent =
| {
type: "GET_BANNER"
}
| { type: "SET_PREVIEW_BANNER"; serviceBanner: ServiceBanner }
| { type: "SET_BANNER"; serviceBanner: ServiceBanner }
const emptyBanner = {
enabled: false,
}
export const serviceBannerMachine = createMachine(
{
id: "serviceBannerMachine",
predictableActionArguments: true,
tsTypes: {} as import("./serviceBannerXService.typegen").Typegen0,
schema: {
context: {} as ServiceBannerContext,
events: {} as ServiceBannerEvent,
services: {
getServiceBanner: {
data: {} as ServiceBanner,
},
setServiceBanner: {
data: {},
},
},
},
context: {
serviceBanner: emptyBanner,
preview: false,
},
initial: "idle",
states: {
idle: {
on: {
GET_BANNER: "gettingBanner",
SET_PREVIEW_BANNER: "settingPreviewBanner",
SET_BANNER: "settingBanner",
},
},
gettingBanner: {
entry: "clearGetBannerError",
invoke: {
id: "getBanner",
src: "getBanner",
onDone: {
target: "idle",
actions: ["assignBanner"],
},
onError: {
target: "idle",
actions: ["assignGetBannerError"],
},
},
},
settingPreviewBanner: {
entry: [
"clearGetBannerError",
"clearSetBannerError",
"assignPreviewBanner",
],
always: {
target: "idle",
},
},
settingBanner: {
entry: "clearSetBannerError",
invoke: {
id: "setBanner",
src: "setBanner",
onDone: {
target: "idle",
actions: ["assignBanner", "notifyUpdateBannerSuccess"],
},
onError: {
target: "idle",
actions: ["assignSetBannerError"],
},
},
},
},
},
{
actions: {
assignPreviewBanner: assign({
serviceBanner: (_, event) => event.serviceBanner,
// The xState docs suggest that we can use a static value, but I failed
// to find a way to do that that doesn't generate type errors.
preview: (_, __) => true,
}),
notifyUpdateBannerSuccess: () => {
displaySuccess("Successfully updated Service Banner!")
},
assignBanner: assign({
serviceBanner: (_, event) => event.data as ServiceBanner,
preview: (_, __) => false,
}),
assignGetBannerError: assign({
getServiceBannerError: (_, event) => event.data,
}),
clearGetBannerError: assign({
getServiceBannerError: (_) => undefined,
}),
assignSetBannerError: assign({
setServiceBannerError: (_, event) => event.data,
}),
clearSetBannerError: assign({
setServiceBannerError: (_) => undefined,
}),
},
services: {
getBanner: API.getServiceBanner,
setBanner: (_, event) => API.setServiceBanner(event.serviceBanner),
},
},
)