mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
feat(site): Add Admin Dropdown menu (#885)
* Start porting components for Admin menu * More porting, wip * Add icons * Extract arrow components, navHeight * Add Admin Dropdown * Format * Delete types * Fix styles * Lint * Add stub pages * Use navHeight constant * Move files * Add and organize stories * Storybook and organize text stories * Add test * Lint * Lint * Fix double navigation * Lint * Wrap new routes in AuthAndNav * Undo unrelated storybook changes * Refactor according to conventions
This commit is contained in:
@ -7,13 +7,16 @@ import { NotFoundPage } from "./pages/404"
|
|||||||
import { CliAuthenticationPage } from "./pages/cli-auth"
|
import { CliAuthenticationPage } from "./pages/cli-auth"
|
||||||
import { HealthzPage } from "./pages/healthz"
|
import { HealthzPage } from "./pages/healthz"
|
||||||
import { SignInPage } from "./pages/login"
|
import { SignInPage } from "./pages/login"
|
||||||
|
import { OrganizationsPage } from "./pages/orgs"
|
||||||
import { PreferencesAccountPage } from "./pages/preferences/account"
|
import { PreferencesAccountPage } from "./pages/preferences/account"
|
||||||
import { PreferencesLinkedAccountsPage } from "./pages/preferences/linked-accounts"
|
import { PreferencesLinkedAccountsPage } from "./pages/preferences/linked-accounts"
|
||||||
import { PreferencesSecurityPage } from "./pages/preferences/security"
|
import { PreferencesSecurityPage } from "./pages/preferences/security"
|
||||||
import { PreferencesSSHKeysPage } from "./pages/preferences/ssh-keys"
|
import { PreferencesSSHKeysPage } from "./pages/preferences/ssh-keys"
|
||||||
|
import { SettingsPage } from "./pages/settings"
|
||||||
import { TemplatesPage } from "./pages/templates"
|
import { TemplatesPage } from "./pages/templates"
|
||||||
import { TemplatePage } from "./pages/templates/[organization]/[template]"
|
import { TemplatePage } from "./pages/templates/[organization]/[template]"
|
||||||
import { CreateWorkspacePage } from "./pages/templates/[organization]/[template]/create"
|
import { CreateWorkspacePage } from "./pages/templates/[organization]/[template]/create"
|
||||||
|
import { UsersPage } from "./pages/users"
|
||||||
import { WorkspacePage } from "./pages/workspaces/[workspace]"
|
import { WorkspacePage } from "./pages/workspaces/[workspace]"
|
||||||
|
|
||||||
export const AppRouter: React.FC = () => (
|
export const AppRouter: React.FC = () => (
|
||||||
@ -72,6 +75,31 @@ export const AppRouter: React.FC = () => (
|
|||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="users"
|
||||||
|
element={
|
||||||
|
<AuthAndNav>
|
||||||
|
<UsersPage />
|
||||||
|
</AuthAndNav>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="orgs"
|
||||||
|
element={
|
||||||
|
<AuthAndNav>
|
||||||
|
<OrganizationsPage />
|
||||||
|
</AuthAndNav>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="settings"
|
||||||
|
element={
|
||||||
|
<AuthAndNav>
|
||||||
|
<SettingsPage />
|
||||||
|
</AuthAndNav>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route path="preferences" element={<PreferencesLayout />}>
|
<Route path="preferences" element={<PreferencesLayout />}>
|
||||||
<Route path="account" element={<PreferencesAccountPage />} />
|
<Route path="account" element={<PreferencesAccountPage />} />
|
||||||
<Route path="security" element={<PreferencesSecurityPage />} />
|
<Route path="security" element={<PreferencesSecurityPage />} />
|
||||||
|
17
site/src/components/AdminDropdown/AdminDropdown.stories.tsx
Normal file
17
site/src/components/AdminDropdown/AdminDropdown.stories.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import Box from "@material-ui/core/Box"
|
||||||
|
import { Story } from "@storybook/react"
|
||||||
|
import React from "react"
|
||||||
|
import { AdminDropdown } from "./AdminDropdown"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "components/AdminDropdown",
|
||||||
|
component: AdminDropdown,
|
||||||
|
}
|
||||||
|
|
||||||
|
const Template: Story = () => (
|
||||||
|
<Box style={{ backgroundColor: "#000", width: 100 }}>
|
||||||
|
<AdminDropdown />
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Example = Template.bind({})
|
48
site/src/components/AdminDropdown/AdminDropdown.test.tsx
Normal file
48
site/src/components/AdminDropdown/AdminDropdown.test.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { screen } from "@testing-library/react"
|
||||||
|
import React from "react"
|
||||||
|
import { history, render } from "../../test_helpers"
|
||||||
|
import { AdminDropdown, Language } from "./AdminDropdown"
|
||||||
|
|
||||||
|
const renderAndClick = async () => {
|
||||||
|
render(<AdminDropdown />)
|
||||||
|
const trigger = await screen.findByText(Language.menuTitle)
|
||||||
|
trigger.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AdminDropdown", () => {
|
||||||
|
describe("when the trigger is clicked", () => {
|
||||||
|
it("opens the menu", async () => {
|
||||||
|
await renderAndClick()
|
||||||
|
expect(screen.getByText(Language.usersLabel)).toBeDefined()
|
||||||
|
expect(screen.getByText(Language.orgsLabel)).toBeDefined()
|
||||||
|
expect(screen.getByText(Language.settingsLabel)).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("links to the users page", async () => {
|
||||||
|
await renderAndClick()
|
||||||
|
|
||||||
|
const usersLink = screen.getByText(Language.usersLabel).closest("a")
|
||||||
|
usersLink?.click()
|
||||||
|
|
||||||
|
expect(history.location.pathname).toEqual("/users")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("links to the orgs page", async () => {
|
||||||
|
await renderAndClick()
|
||||||
|
|
||||||
|
const usersLink = screen.getByText(Language.orgsLabel).closest("a")
|
||||||
|
usersLink?.click()
|
||||||
|
|
||||||
|
expect(history.location.pathname).toEqual("/orgs")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("links to the settings page", async () => {
|
||||||
|
await renderAndClick()
|
||||||
|
|
||||||
|
const usersLink = screen.getByText(Language.settingsLabel).closest("a")
|
||||||
|
usersLink?.click()
|
||||||
|
|
||||||
|
expect(history.location.pathname).toEqual("/settings")
|
||||||
|
})
|
||||||
|
})
|
155
site/src/components/AdminDropdown/AdminDropdown.tsx
Normal file
155
site/src/components/AdminDropdown/AdminDropdown.tsx
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import ListItem from "@material-ui/core/ListItem"
|
||||||
|
import ListItemText from "@material-ui/core/ListItemText"
|
||||||
|
import { fade, makeStyles, Theme } from "@material-ui/core/styles"
|
||||||
|
import AdminIcon from "@material-ui/icons/SettingsOutlined"
|
||||||
|
import React, { useState } from "react"
|
||||||
|
import { navHeight } from "../../theme/constants"
|
||||||
|
import { BorderedMenu } from "../BorderedMenu/BorderedMenu"
|
||||||
|
import { BorderedMenuRow } from "../BorderedMenuRow/BorderedMenuRow"
|
||||||
|
import { CloseDropdown, OpenDropdown } from "../DropdownArrows/DropdownArrows"
|
||||||
|
import { BuildingIcon } from "../Icons/BuildingIcon"
|
||||||
|
import { UsersOutlinedIcon } from "../Icons/UsersOutlinedIcon"
|
||||||
|
|
||||||
|
export const Language = {
|
||||||
|
menuTitle: "Admin",
|
||||||
|
usersLabel: "Users",
|
||||||
|
usersDescription: "Manage users, roles, and permissions.",
|
||||||
|
orgsLabel: "Organizations",
|
||||||
|
orgsDescription: "Manage organizations.",
|
||||||
|
settingsLabel: "Settings",
|
||||||
|
settingsDescription: "Configure authentication and more.",
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
label: Language.usersLabel,
|
||||||
|
description: Language.usersDescription,
|
||||||
|
path: "/users",
|
||||||
|
Icon: UsersOutlinedIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: Language.orgsLabel,
|
||||||
|
description: Language.orgsDescription,
|
||||||
|
path: "/orgs",
|
||||||
|
Icon: BuildingIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: Language.settingsLabel,
|
||||||
|
description: Language.settingsDescription,
|
||||||
|
path: "/settings",
|
||||||
|
Icon: AdminIcon,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const AdminDropdown: React.FC = () => {
|
||||||
|
const styles = useStyles()
|
||||||
|
const [anchorEl, setAnchorEl] = useState<HTMLElement>()
|
||||||
|
const onClose = () => setAnchorEl(undefined)
|
||||||
|
const onOpenAdminMenu = (ev: React.MouseEvent<HTMLDivElement>) => setAnchorEl(ev.currentTarget)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.link}>
|
||||||
|
<ListItem selected={Boolean(anchorEl)} button onClick={onOpenAdminMenu}>
|
||||||
|
<ListItemText className="no-brace" color="primary" primary={Language.menuTitle} />
|
||||||
|
{anchorEl ? <CloseDropdown /> : <OpenDropdown />}
|
||||||
|
</ListItem>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BorderedMenu
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
getContentAnchorEl={null}
|
||||||
|
open={!!anchorEl}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: "bottom",
|
||||||
|
horizontal: "center",
|
||||||
|
}}
|
||||||
|
transformOrigin={{
|
||||||
|
vertical: "top",
|
||||||
|
horizontal: "center",
|
||||||
|
}}
|
||||||
|
marginThreshold={0}
|
||||||
|
variant="admin-dropdown"
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<BorderedMenuRow
|
||||||
|
description={entry.description}
|
||||||
|
Icon={entry.Icon}
|
||||||
|
key={entry.label}
|
||||||
|
path={entry.path}
|
||||||
|
title={entry.label}
|
||||||
|
variant="narrow"
|
||||||
|
onClick={() => {
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BorderedMenu>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) => ({
|
||||||
|
link: {
|
||||||
|
"&:focus": {
|
||||||
|
outline: "none",
|
||||||
|
|
||||||
|
"& .MuiListItem-button": {
|
||||||
|
background: fade(theme.palette.primary.light, 0.1),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"& .MuiListItemText-root": {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
"& .feature-stage-chip": {
|
||||||
|
position: "absolute",
|
||||||
|
bottom: theme.spacing(1),
|
||||||
|
|
||||||
|
"& .MuiChip-labelSmall": {
|
||||||
|
fontSize: "10px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
"& .MuiListItem-button": {
|
||||||
|
height: navHeight,
|
||||||
|
color: "#A7A7A7",
|
||||||
|
padding: `0 ${theme.spacing(3)}px`,
|
||||||
|
|
||||||
|
"&.Mui-selected": {
|
||||||
|
background: "transparent",
|
||||||
|
"& .MuiListItemText-root": {
|
||||||
|
color: theme.palette.primary.contrastText,
|
||||||
|
|
||||||
|
"&:not(.no-brace) .MuiTypography-root": {
|
||||||
|
position: "relative",
|
||||||
|
|
||||||
|
"&::before": {
|
||||||
|
content: `"{"`,
|
||||||
|
left: -14,
|
||||||
|
position: "absolute",
|
||||||
|
},
|
||||||
|
"&::after": {
|
||||||
|
content: `"}"`,
|
||||||
|
position: "absolute",
|
||||||
|
right: -14,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"&.Mui-focusVisible, &:hover": {
|
||||||
|
background: "#333",
|
||||||
|
},
|
||||||
|
|
||||||
|
"& .MuiListItemText-primary": {
|
||||||
|
fontFamily: theme.typography.fontFamily,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
30
site/src/components/BorderedMenu/BorderedMenu.stories.tsx
Normal file
30
site/src/components/BorderedMenu/BorderedMenu.stories.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { Story } from "@storybook/react"
|
||||||
|
import React from "react"
|
||||||
|
import { BorderedMenuRow } from "../BorderedMenuRow/BorderedMenuRow"
|
||||||
|
import { BuildingIcon } from "../Icons/BuildingIcon"
|
||||||
|
import { UsersOutlinedIcon } from "../Icons/UsersOutlinedIcon"
|
||||||
|
import { BorderedMenu, BorderedMenuProps } from "./BorderedMenu"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "components/BorderedMenu",
|
||||||
|
component: BorderedMenu,
|
||||||
|
}
|
||||||
|
|
||||||
|
const Template: Story<BorderedMenuProps> = (args: BorderedMenuProps) => (
|
||||||
|
<BorderedMenu {...args}>
|
||||||
|
<BorderedMenuRow title="Item 1" description="Here's a description" Icon={BuildingIcon} />
|
||||||
|
<BorderedMenuRow active title="Item 2" description="This BorderedMenuRow is active" Icon={UsersOutlinedIcon} />
|
||||||
|
</BorderedMenu>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const AdminVariant = Template.bind({})
|
||||||
|
AdminVariant.args = {
|
||||||
|
variant: "admin-dropdown",
|
||||||
|
open: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserVariant = Template.bind({})
|
||||||
|
UserVariant.args = {
|
||||||
|
variant: "user-dropdown",
|
||||||
|
open: true,
|
||||||
|
}
|
@ -2,9 +2,9 @@ import Popover, { PopoverProps } from "@material-ui/core/Popover"
|
|||||||
import { fade, makeStyles } from "@material-ui/core/styles"
|
import { fade, makeStyles } from "@material-ui/core/styles"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
|
|
||||||
type BorderedMenuVariant = "manage-dropdown" | "user-dropdown"
|
type BorderedMenuVariant = "admin-dropdown" | "user-dropdown"
|
||||||
|
|
||||||
type BorderedMenuProps = Omit<PopoverProps, "variant"> & {
|
export type BorderedMenuProps = Omit<PopoverProps, "variant"> & {
|
||||||
variant?: BorderedMenuVariant
|
variant?: BorderedMenuVariant
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -20,7 +20,14 @@ export const BorderedMenu: React.FC<BorderedMenuProps> = ({ children, variant, .
|
|||||||
|
|
||||||
const useStyles = makeStyles((theme) => ({
|
const useStyles = makeStyles((theme) => ({
|
||||||
root: {
|
root: {
|
||||||
paddingBottom: theme.spacing(1),
|
"&[data-variant='admin-dropdown'] $paperRoot": {
|
||||||
|
padding: `${theme.spacing(3)}px 0`,
|
||||||
|
},
|
||||||
|
|
||||||
|
"&[data-variant='user-dropdown'] $paperRoot": {
|
||||||
|
paddingBottom: theme.spacing(1),
|
||||||
|
width: 292,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
paperRoot: {
|
paperRoot: {
|
||||||
width: "292px",
|
width: "292px",
|
144
site/src/components/BorderedMenuRow/BorderedMenuRow.tsx
Normal file
144
site/src/components/BorderedMenuRow/BorderedMenuRow.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import ListItem from "@material-ui/core/ListItem"
|
||||||
|
import { makeStyles } from "@material-ui/core/styles"
|
||||||
|
import SvgIcon from "@material-ui/core/SvgIcon"
|
||||||
|
import CheckIcon from "@material-ui/icons/Check"
|
||||||
|
import React from "react"
|
||||||
|
import { NavLink } from "react-router-dom"
|
||||||
|
import { ellipsizeText } from "../../util/ellipsizeText"
|
||||||
|
import { Typography } from "../Typography/Typography"
|
||||||
|
|
||||||
|
type BorderedMenuRowVariant = "narrow" | "wide"
|
||||||
|
|
||||||
|
interface BorderedMenuRowProps {
|
||||||
|
/** `true` indicates this row is currently selected */
|
||||||
|
active?: boolean
|
||||||
|
/** Optional description that appears beneath the title */
|
||||||
|
description?: string
|
||||||
|
/** An SvgIcon that will be rendered to the left of the title */
|
||||||
|
Icon: typeof SvgIcon
|
||||||
|
/** URL path */
|
||||||
|
path?: string
|
||||||
|
/** Required title of this row */
|
||||||
|
title: string
|
||||||
|
/** Defaults to `"wide"` */
|
||||||
|
variant?: BorderedMenuRowVariant
|
||||||
|
/** Callback fired when this row is clicked */
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BorderedMenuRow: React.FC<BorderedMenuRowProps> = ({
|
||||||
|
active,
|
||||||
|
description,
|
||||||
|
Icon,
|
||||||
|
path,
|
||||||
|
title,
|
||||||
|
variant,
|
||||||
|
onClick,
|
||||||
|
}) => {
|
||||||
|
const styles = useStyles()
|
||||||
|
|
||||||
|
const Component = () => (
|
||||||
|
<ListItem
|
||||||
|
classes={{ gutters: styles.rootGutters }}
|
||||||
|
className={styles.root}
|
||||||
|
onClick={onClick}
|
||||||
|
data-status={active ? "active" : "inactive"}
|
||||||
|
>
|
||||||
|
<div className={styles.content} data-variant={variant}>
|
||||||
|
<div className={styles.contentTop}>
|
||||||
|
<Icon className={styles.icon} />
|
||||||
|
<Typography className={styles.title}>{title}</Typography>
|
||||||
|
{active && <CheckIcon className={styles.checkMark} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{description && (
|
||||||
|
<Typography className={styles.description} color="textSecondary" variant="caption">
|
||||||
|
{ellipsizeText(description)}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ListItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (path) {
|
||||||
|
return (
|
||||||
|
<NavLink to={path} className={styles.link}>
|
||||||
|
<Component />
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Component />
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconSize = 20
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme) => ({
|
||||||
|
root: {
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: `0 ${theme.spacing(1)}px`,
|
||||||
|
|
||||||
|
"&:hover": {
|
||||||
|
backgroundColor: "unset",
|
||||||
|
"& $content": {
|
||||||
|
backgroundColor: theme.palette.background.default,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"&[data-status='active']": {
|
||||||
|
color: theme.palette.primary.main,
|
||||||
|
"& .BorderedMenuRow-description": {
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
},
|
||||||
|
"& .BorderedMenuRow-icon": {
|
||||||
|
color: theme.palette.primary.main,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rootGutters: {
|
||||||
|
padding: `0 ${theme.spacing(1.5)}px`,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
borderRadius: 7,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
padding: theme.spacing(2),
|
||||||
|
width: 320,
|
||||||
|
|
||||||
|
"&[data-variant='narrow']": {
|
||||||
|
width: 268,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
contentTop: {
|
||||||
|
alignItems: "center",
|
||||||
|
display: "flex",
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
height: iconSize,
|
||||||
|
width: iconSize,
|
||||||
|
|
||||||
|
"& path": {
|
||||||
|
fill: theme.palette.text.secondary,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
textDecoration: "none",
|
||||||
|
color: "inherit",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
marginLeft: theme.spacing(2),
|
||||||
|
},
|
||||||
|
checkMark: {
|
||||||
|
height: iconSize,
|
||||||
|
marginLeft: "auto",
|
||||||
|
width: iconSize,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
marginLeft: theme.spacing(4.5),
|
||||||
|
marginTop: theme.spacing(0.5),
|
||||||
|
},
|
||||||
|
}))
|
26
site/src/components/DropdownArrows/DropdownArrows.tsx
Normal file
26
site/src/components/DropdownArrows/DropdownArrows.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { fade, makeStyles, Theme } from "@material-ui/core/styles"
|
||||||
|
import KeyboardArrowDown from "@material-ui/icons/KeyboardArrowDown"
|
||||||
|
import KeyboardArrowUp from "@material-ui/icons/KeyboardArrowUp"
|
||||||
|
import React from "react"
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) => ({
|
||||||
|
arrowIcon: {
|
||||||
|
color: fade(theme.palette.primary.contrastText, 0.7),
|
||||||
|
marginLeft: theme.spacing(1),
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
},
|
||||||
|
arrowIconUp: {
|
||||||
|
color: theme.palette.primary.contrastText,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const OpenDropdown: React.FC = () => {
|
||||||
|
const styles = useStyles()
|
||||||
|
return <KeyboardArrowDown className={styles.arrowIcon} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CloseDropdown: React.FC = () => {
|
||||||
|
const styles = useStyles()
|
||||||
|
return <KeyboardArrowUp className={`${styles.arrowIcon} ${styles.arrowIconUp}`} />
|
||||||
|
}
|
13
site/src/components/Icons/BuildingIcon.tsx
Normal file
13
site/src/components/Icons/BuildingIcon.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import SvgIcon, { SvgIconProps } from "@material-ui/core/SvgIcon"
|
||||||
|
import React from "react"
|
||||||
|
|
||||||
|
export const BuildingIcon = (props: SvgIconProps): JSX.Element => (
|
||||||
|
<SvgIcon {...props} viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
clipRule="evenodd"
|
||||||
|
d="M2.96387 1.15222H17.3405V11.2377H21V22.8479L17.3405 22.8479L10.9364 22.8479H9.36801L2.96387 22.8479V1.15222ZM10.9364 21.2795V19.4498H9.36801V21.2795H4.53223V2.72058H15.7722V11.2377H15.7721V12.806H15.7722V21.2795H10.9364ZM17.3405 12.806V21.2795H19.4317V12.806H17.3405ZM9.10661 4.81173H6.62337V6.38009H9.10661V4.81173ZM11.1978 4.81173H13.681V6.38009H11.1978V4.81173ZM9.10661 8.47124H6.62337V10.0396H9.10661V8.47124ZM11.1978 8.47124H13.681V10.0396H11.1978V8.47124ZM9.10661 12.1307H6.62337V13.6991H9.10661V12.1307ZM11.1978 12.1307H13.681V13.6991H11.1978V12.1307ZM9.10661 15.7903H6.62337V17.3586H9.10661V15.7903ZM11.1978 15.7903H13.681V17.3586H11.1978V15.7903Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</SvgIcon>
|
||||||
|
)
|
25
site/src/components/Icons/UsersOutlinedIcon.tsx
Normal file
25
site/src/components/Icons/UsersOutlinedIcon.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import SvgIcon from "@material-ui/core/SvgIcon"
|
||||||
|
import React from "react"
|
||||||
|
|
||||||
|
export const UsersOutlinedIcon: typeof SvgIcon = (props) => (
|
||||||
|
<SvgIcon {...props} viewBox="0 0 20 20">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path
|
||||||
|
d="M18.75 18.75H17.5V15.625V15.625C17.498 13.8999 16.1001 12.502 14.375 12.5V11.25C16.7901 11.2527 18.7473 13.2099 18.75 15.625L18.75 18.75Z"
|
||||||
|
fill="#677693"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M13.75 18.75H12.5V15.625V15.6251C12.498 13.9001 11.1002 12.5021 9.3751 12.5H5.625C3.89995 12.502 2.50203 13.9 2.5 15.625V18.75H1.25V15.625L1.25 15.6251C1.25277 13.21 3.20982 11.2529 5.62489 11.25H9.37489C11.79 11.2528 13.7471 13.2099 13.7499 15.625L13.75 18.75Z"
|
||||||
|
fill="#677693"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M12.5 1.25V2.5C14.2259 2.5 15.625 3.89911 15.625 5.625C15.625 7.35089 14.2259 8.75 12.5 8.75V10C14.9162 10 16.875 8.04124 16.875 5.625C16.875 3.20876 14.9162 1.25 12.5 1.25Z"
|
||||||
|
fill="#677693"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M7.5 2.5C9.22589 2.5 10.625 3.89911 10.625 5.625C10.625 7.35089 9.22589 8.75 7.5 8.75C5.77411 8.75 4.375 7.35089 4.375 5.625V5.625C4.375 3.89911 5.77411 2.5 7.5 2.5V2.5ZM7.5 1.25H7.5C5.08376 1.25 3.125 3.20876 3.125 5.625C3.125 8.04124 5.08376 10 7.5 10C9.91624 10 11.875 8.04124 11.875 5.625C11.875 3.20876 9.91624 1.25 7.5 1.25L7.5 1.25Z"
|
||||||
|
fill="#677693"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</SvgIcon>
|
||||||
|
)
|
@ -1,9 +1,9 @@
|
|||||||
import { Story } from "@storybook/react"
|
import { Story } from "@storybook/react"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { NavbarView, NavbarViewProps } from "./NavbarView"
|
import { NavbarView, NavbarViewProps } from "."
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "Page/NavbarView",
|
title: "components/NavbarView",
|
||||||
component: NavbarView,
|
component: NavbarView,
|
||||||
argTypes: {
|
argTypes: {
|
||||||
onSignOut: { action: "Sign Out" },
|
onSignOut: { action: "Sign Out" },
|
||||||
@ -12,8 +12,16 @@ export default {
|
|||||||
|
|
||||||
const Template: Story<NavbarViewProps> = (args: NavbarViewProps) => <NavbarView {...args} />
|
const Template: Story<NavbarViewProps> = (args: NavbarViewProps) => <NavbarView {...args} />
|
||||||
|
|
||||||
export const Primary = Template.bind({})
|
export const ForAdmin = Template.bind({})
|
||||||
Primary.args = {
|
ForAdmin.args = {
|
||||||
|
user: { id: "1", username: "Administrator", email: "admin@coder.com", created_at: "dawn" },
|
||||||
|
onSignOut: () => {
|
||||||
|
return Promise.resolve()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ForMember = Template.bind({})
|
||||||
|
ForMember.args = {
|
||||||
user: { id: "1", username: "CathyCoder", email: "cathy@coder.com", created_at: "dawn" },
|
user: { id: "1", username: "CathyCoder", email: "cathy@coder.com", created_at: "dawn" },
|
||||||
onSignOut: () => {
|
onSignOut: () => {
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
@ -1,8 +1,8 @@
|
|||||||
import { screen } from "@testing-library/react"
|
import { screen } from "@testing-library/react"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { render } from "../../test_helpers"
|
import { NavbarView } from "."
|
||||||
import { MockUser } from "../../test_helpers/entities"
|
import { render } from "../../../test_helpers"
|
||||||
import { NavbarView } from "./NavbarView"
|
import { MockUser } from "../../../test_helpers/entities"
|
||||||
|
|
||||||
describe("NavbarView", () => {
|
describe("NavbarView", () => {
|
||||||
const noop = () => {
|
const noop = () => {
|
@ -3,9 +3,11 @@ import ListItem from "@material-ui/core/ListItem"
|
|||||||
import { fade, makeStyles } from "@material-ui/core/styles"
|
import { fade, makeStyles } from "@material-ui/core/styles"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { NavLink } from "react-router-dom"
|
import { NavLink } from "react-router-dom"
|
||||||
import { UserResponse } from "../../api/types"
|
import { UserResponse } from "../../../api/types"
|
||||||
import { Logo } from "../Icons"
|
import { navHeight } from "../../../theme/constants"
|
||||||
import { UserDropdown } from "./UserDropdown"
|
import { AdminDropdown } from "../../AdminDropdown/AdminDropdown"
|
||||||
|
import { Logo } from "../../Icons"
|
||||||
|
import { UserDropdown } from "../UserDropdown"
|
||||||
|
|
||||||
export interface NavbarViewProps {
|
export interface NavbarViewProps {
|
||||||
user?: UserResponse
|
user?: UserResponse
|
||||||
@ -29,6 +31,7 @@ export const NavbarView: React.FC<NavbarViewProps> = ({ user, onSignOut }) => {
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
</List>
|
</List>
|
||||||
<div className={styles.fullWidth} />
|
<div className={styles.fullWidth} />
|
||||||
|
{user && user.email === "admin@coder.com" && <AdminDropdown />}
|
||||||
<div className={styles.fixed}>{user && <UserDropdown user={user} onSignOut={onSignOut} />}</div>
|
<div className={styles.fixed}>{user && <UserDropdown user={user} onSignOut={onSignOut} />}</div>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
@ -42,7 +45,7 @@ const useStyles = makeStyles((theme) => ({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
height: 56,
|
height: navHeight,
|
||||||
background: theme.palette.navbar.main,
|
background: theme.palette.navbar.main,
|
||||||
marginTop: 0,
|
marginTop: 0,
|
||||||
transition: "margin 150ms ease",
|
transition: "margin 150ms ease",
|
||||||
@ -62,7 +65,7 @@ const useStyles = makeStyles((theme) => ({
|
|||||||
logo: {
|
logo: {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
height: 56,
|
height: navHeight,
|
||||||
paddingLeft: theme.spacing(4),
|
paddingLeft: theme.spacing(4),
|
||||||
paddingRight: theme.spacing(2),
|
paddingRight: theme.spacing(2),
|
||||||
"& svg": {
|
"& svg": {
|
||||||
@ -81,7 +84,7 @@ const useStyles = makeStyles((theme) => ({
|
|||||||
color: "#A7A7A7",
|
color: "#A7A7A7",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
height: 56,
|
height: navHeight,
|
||||||
padding: `0 ${theme.spacing(3)}px`,
|
padding: `0 ${theme.spacing(3)}px`,
|
||||||
textDecoration: "none",
|
textDecoration: "none",
|
||||||
transition: "background-color 0.3s ease",
|
transition: "background-color 0.3s ease",
|
@ -1,7 +1,7 @@
|
|||||||
import Box from "@material-ui/core/Box"
|
import Box from "@material-ui/core/Box"
|
||||||
import { Story } from "@storybook/react"
|
import { Story } from "@storybook/react"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { UserDropdown, UserDropdownProps } from "./UserDropdown"
|
import { UserDropdown, UserDropdownProps } from "."
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: "Page/UserDropdown",
|
title: "Page/UserDropdown",
|
@ -1,8 +1,8 @@
|
|||||||
import { screen } from "@testing-library/react"
|
import { screen } from "@testing-library/react"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { render } from "../../test_helpers"
|
import { Language, UserDropdown, UserDropdownProps } from "."
|
||||||
import { MockUser } from "../../test_helpers/entities"
|
import { render } from "../../../test_helpers"
|
||||||
import { Language, UserDropdown, UserDropdownProps } from "./UserDropdown"
|
import { MockUser } from "../../../test_helpers/entities"
|
||||||
|
|
||||||
const renderAndClick = async (props: Partial<UserDropdownProps> = {}) => {
|
const renderAndClick = async (props: Partial<UserDropdownProps> = {}) => {
|
||||||
render(<UserDropdown user={props.user ?? MockUser} onSignOut={props.onSignOut ?? jest.fn()} />)
|
render(<UserDropdown user={props.user ?? MockUser} onSignOut={props.onSignOut ?? jest.fn()} />)
|
@ -5,16 +5,15 @@ import ListItemText from "@material-ui/core/ListItemText"
|
|||||||
import MenuItem from "@material-ui/core/MenuItem"
|
import MenuItem from "@material-ui/core/MenuItem"
|
||||||
import { fade, makeStyles } from "@material-ui/core/styles"
|
import { fade, makeStyles } from "@material-ui/core/styles"
|
||||||
import AccountIcon from "@material-ui/icons/AccountCircleOutlined"
|
import AccountIcon from "@material-ui/icons/AccountCircleOutlined"
|
||||||
import KeyboardArrowDown from "@material-ui/icons/KeyboardArrowDown"
|
|
||||||
import KeyboardArrowUp from "@material-ui/icons/KeyboardArrowUp"
|
|
||||||
import React, { useState } from "react"
|
import React, { useState } from "react"
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
import { UserResponse } from "../../api/types"
|
import { UserResponse } from "../../../api/types"
|
||||||
import { LogoutIcon } from "../Icons"
|
import { BorderedMenu } from "../../BorderedMenu/BorderedMenu"
|
||||||
import { DocsIcon } from "../Icons/DocsIcon"
|
import { CloseDropdown, OpenDropdown } from "../../DropdownArrows/DropdownArrows"
|
||||||
import { UserAvatar } from "../User"
|
import { LogoutIcon } from "../../Icons"
|
||||||
import { UserProfileCard } from "../User/UserProfileCard"
|
import { DocsIcon } from "../../Icons/DocsIcon"
|
||||||
import { BorderedMenu } from "./BorderedMenu"
|
import { UserAvatar } from "../../User"
|
||||||
|
import { UserProfileCard } from "../../User/UserProfileCard"
|
||||||
|
|
||||||
export const Language = {
|
export const Language = {
|
||||||
accountLabel: "Account",
|
accountLabel: "Account",
|
||||||
@ -44,11 +43,7 @@ export const UserDropdown: React.FC<UserDropdownProps> = ({ user, onSignOut }: U
|
|||||||
<Badge overlap="circle">
|
<Badge overlap="circle">
|
||||||
<UserAvatar username={user.username} />
|
<UserAvatar username={user.username} />
|
||||||
</Badge>
|
</Badge>
|
||||||
{anchorEl ? (
|
{anchorEl ? <CloseDropdown /> : <OpenDropdown />}
|
||||||
<KeyboardArrowUp className={`${styles.arrowIcon} ${styles.arrowIconUp}`} />
|
|
||||||
) : (
|
|
||||||
<KeyboardArrowDown className={styles.arrowIcon} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
|
||||||
@ -120,17 +115,6 @@ export const useStyles = makeStyles((theme) => ({
|
|||||||
marginBottom: theme.spacing(1),
|
marginBottom: theme.spacing(1),
|
||||||
},
|
},
|
||||||
|
|
||||||
arrowIcon: {
|
|
||||||
color: fade(theme.palette.primary.contrastText, 0.7),
|
|
||||||
marginLeft: theme.spacing(1),
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
},
|
|
||||||
|
|
||||||
arrowIconUp: {
|
|
||||||
color: theme.palette.primary.contrastText,
|
|
||||||
},
|
|
||||||
|
|
||||||
menuItem: {
|
menuItem: {
|
||||||
height: 44,
|
height: 44,
|
||||||
padding: `${theme.spacing(1.5)}px ${theme.spacing(2.75)}px`,
|
padding: `${theme.spacing(1.5)}px ${theme.spacing(2.75)}px`,
|
@ -3,7 +3,7 @@ import ListItem from "@material-ui/core/ListItem"
|
|||||||
import { makeStyles } from "@material-ui/core/styles"
|
import { makeStyles } from "@material-ui/core/styles"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { NavLink } from "react-router-dom"
|
import { NavLink } from "react-router-dom"
|
||||||
import { combineClasses } from "../../util/combine-classes"
|
import { combineClasses } from "../../util/combineClasses"
|
||||||
|
|
||||||
export interface TabSidebarItem {
|
export interface TabSidebarItem {
|
||||||
path: string
|
path: string
|
||||||
|
24
site/src/components/Typography/Typography.stories.tsx
Normal file
24
site/src/components/Typography/Typography.stories.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Story } from "@storybook/react"
|
||||||
|
import React from "react"
|
||||||
|
import { Typography, TypographyProps } from "./Typography"
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: "components/Typography",
|
||||||
|
component: Typography,
|
||||||
|
}
|
||||||
|
|
||||||
|
const Template: Story<TypographyProps> = (args: TypographyProps) => (
|
||||||
|
<>
|
||||||
|
<Typography {...args}>Colorless green ideas sleep furiously</Typography>
|
||||||
|
<Typography {...args}>More people have been to France than I have</Typography>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Short = Template.bind({})
|
||||||
|
Short.args = {
|
||||||
|
short: true,
|
||||||
|
}
|
||||||
|
export const Tall = Template.bind({})
|
||||||
|
Tall.args = {
|
||||||
|
short: false,
|
||||||
|
}
|
42
site/src/components/Typography/Typography.tsx
Normal file
42
site/src/components/Typography/Typography.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview (TODO: Grey) This file is in a temporary state and is a
|
||||||
|
* verbatim port from `@coder/ui`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { makeStyles } from "@material-ui/core/styles"
|
||||||
|
import MuiTypography, { TypographyProps as MuiTypographyProps } from "@material-ui/core/Typography"
|
||||||
|
import * as React from "react"
|
||||||
|
import { appendCSSString, combineClasses } from "../../util/combineClasses"
|
||||||
|
|
||||||
|
export interface TypographyProps extends MuiTypographyProps {
|
||||||
|
short?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper around Material UI's Typography component to allow for future
|
||||||
|
* custom typography types.
|
||||||
|
*
|
||||||
|
* See original component's Material UI documentation here: https://material-ui.com/components/typography/
|
||||||
|
*/
|
||||||
|
export const Typography: React.FC<TypographyProps> = ({ className, short, ...rest }) => {
|
||||||
|
const styles = useStyles()
|
||||||
|
|
||||||
|
let classes = combineClasses({ [styles.short]: short })
|
||||||
|
if (className) {
|
||||||
|
classes = appendCSSString(classes ?? "", className)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MuiTypography {...rest} className={classes} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
short: {
|
||||||
|
"&.MuiTypography-body1": {
|
||||||
|
lineHeight: "21px",
|
||||||
|
},
|
||||||
|
"&.MuiTypography-body2": {
|
||||||
|
lineHeight: "18px",
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
5
site/src/pages/orgs/index.tsx
Normal file
5
site/src/pages/orgs/index.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
export const OrganizationsPage: React.FC = () => {
|
||||||
|
return <div>Coming soon!</div>
|
||||||
|
}
|
5
site/src/pages/settings/index.tsx
Normal file
5
site/src/pages/settings/index.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
export const SettingsPage: React.FC = () => {
|
||||||
|
return <div>Coming soon!</div>
|
||||||
|
}
|
5
site/src/pages/users.tsx
Normal file
5
site/src/pages/users.tsx
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
export const UsersPage: React.FC = () => {
|
||||||
|
return <div>Coming soon!</div>
|
||||||
|
}
|
@ -6,3 +6,4 @@ export const MONOSPACE_FONT_FAMILY =
|
|||||||
export const BODY_FONT_FAMILY = `"Inter", sans-serif`
|
export const BODY_FONT_FAMILY = `"Inter", sans-serif`
|
||||||
export const lightButtonShadow = "0 2px 2px rgba(0, 23, 121, 0.08)"
|
export const lightButtonShadow = "0 2px 2px rgba(0, 23, 121, 0.08)"
|
||||||
export const emptyBoxShadow = "none"
|
export const emptyBoxShadow = "none"
|
||||||
|
export const navHeight = 56
|
||||||
|
28
site/src/util/combineClasses.test.ts
Normal file
28
site/src/util/combineClasses.test.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { combineClasses } from "./combineClasses"
|
||||||
|
|
||||||
|
const staticStyles = {
|
||||||
|
text: "MuiText",
|
||||||
|
success: "MuiText-Green",
|
||||||
|
warning: "MuiText-Red",
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("combineClasses", () => {
|
||||||
|
it.each([
|
||||||
|
// Falsy
|
||||||
|
[undefined, undefined],
|
||||||
|
[{ [staticStyles.text]: false }, undefined],
|
||||||
|
[{ [staticStyles.text]: undefined }, undefined],
|
||||||
|
[[], undefined],
|
||||||
|
|
||||||
|
// Truthy
|
||||||
|
[{ [staticStyles.text]: true }, "MuiText"],
|
||||||
|
[{ [staticStyles.text]: true, [staticStyles.warning]: true }, "MuiText MuiText-Red"],
|
||||||
|
[[staticStyles.text], "MuiText"],
|
||||||
|
|
||||||
|
// Mixed
|
||||||
|
[{ [staticStyles.text]: true, [staticStyles.success]: false }, "MuiText"],
|
||||||
|
[[staticStyles.text, staticStyles.success], "MuiText MuiText-Green"],
|
||||||
|
])(`classNames(%p) returns %p`, (staticClasses, result) => {
|
||||||
|
expect(combineClasses(staticClasses)).toBe(result)
|
||||||
|
})
|
||||||
|
})
|
17
site/src/util/ellipsizeText.test.ts
Normal file
17
site/src/util/ellipsizeText.test.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { ellipsizeText } from "./ellipsizeText"
|
||||||
|
import { Nullable } from "./nullable"
|
||||||
|
|
||||||
|
describe("ellipsizeText", () => {
|
||||||
|
it.each([
|
||||||
|
[undefined, 10, undefined],
|
||||||
|
[null, 10, undefined],
|
||||||
|
["", 10, ""],
|
||||||
|
["Hello World", "Hello World".length, "Hello World"],
|
||||||
|
["Hello World", "Hello...".length, "Hello..."],
|
||||||
|
])(
|
||||||
|
`ellipsizeText(%p, %p) returns %p`,
|
||||||
|
(str: Nullable<string>, maxLength: number | undefined, output: Nullable<string>) => {
|
||||||
|
expect(ellipsizeText(str, maxLength)).toBe(output)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
9
site/src/util/ellipsizeText.ts
Normal file
9
site/src/util/ellipsizeText.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Nullable } from "./nullable"
|
||||||
|
|
||||||
|
/** Truncates and ellipsizes text if it's longer than maxLength */
|
||||||
|
export const ellipsizeText = (text: Nullable<string>, maxLength = 80): string | undefined => {
|
||||||
|
if (typeof text !== "string") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return text.length <= maxLength ? text : `${text.substr(0, maxLength - 3)}...`
|
||||||
|
}
|
5
site/src/util/nullable.ts
Normal file
5
site/src/util/nullable.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* A Nullable may be its concrete type, `null` or `undefined`
|
||||||
|
* @remark Exact opposite of the native TS type NonNullable<T>
|
||||||
|
*/
|
||||||
|
export type Nullable<T> = null | undefined | T
|
Reference in New Issue
Block a user