mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
refactor: Update users page to looks like others (#1850)
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { Story } from "@storybook/react"
|
||||
import React from "react"
|
||||
import { AvatarData, AvatarDataProps } from "./AvatarData"
|
||||
|
||||
export default {
|
||||
title: "components/AvatarData",
|
||||
component: AvatarData,
|
||||
}
|
||||
|
||||
const Template: Story<AvatarDataProps> = (args: AvatarDataProps) => <AvatarData {...args} />
|
||||
|
||||
export const Example = Template.bind({})
|
||||
Example.args = {
|
||||
title: "coder",
|
||||
subtitle: "coder@coder.com",
|
||||
}
|
||||
|
||||
export const WithLink = Template.bind({})
|
||||
WithLink.args = {
|
||||
title: "coder",
|
||||
subtitle: "coder@coder.com",
|
||||
link: "/users/coder",
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import Avatar from "@material-ui/core/Avatar"
|
||||
import Link from "@material-ui/core/Link"
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import React from "react"
|
||||
import { Link as RouterLink } from "react-router-dom"
|
||||
import { combineClasses } from "../../util/combineClasses"
|
||||
import { firstLetter } from "../../util/firstLetter"
|
||||
|
||||
export interface AvatarDataProps {
|
||||
title: string
|
||||
subtitle: string
|
||||
link?: string
|
||||
}
|
||||
|
||||
export const AvatarData: React.FC<AvatarDataProps> = ({ title, subtitle, link }) => {
|
||||
const styles = useStyles()
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<Avatar variant="square" className={styles.avatar}>
|
||||
{firstLetter(title)}
|
||||
</Avatar>
|
||||
|
||||
{link ? (
|
||||
<Link component={RouterLink} to={link} className={combineClasses([styles.info, styles.link])}>
|
||||
<b>{title}</b>
|
||||
<span>{subtitle}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className={styles.info}>
|
||||
<b>{title}</b>
|
||||
<span>{subtitle}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
avatar: {
|
||||
borderRadius: 2,
|
||||
marginRight: theme.spacing(1),
|
||||
width: 24,
|
||||
height: 24,
|
||||
fontSize: 16,
|
||||
},
|
||||
info: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
color: theme.palette.text.primary,
|
||||
|
||||
"& span": {
|
||||
fontSize: 12,
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
},
|
||||
link: {
|
||||
textDecoration: "none",
|
||||
"&:hover": {
|
||||
textDecoration: "underline",
|
||||
},
|
||||
},
|
||||
}))
|
||||
@@ -1,28 +0,0 @@
|
||||
import { screen } from "@testing-library/react"
|
||||
import React from "react"
|
||||
import { render } from "../../testHelpers/renderHelpers"
|
||||
import { Header } from "./Header"
|
||||
|
||||
describe("Header", () => {
|
||||
it("renders title and subtitle", async () => {
|
||||
// When
|
||||
render(<Header title="Title Test" subTitle="Subtitle Test" />)
|
||||
|
||||
// Then
|
||||
const titleElement = await screen.findByText("Title Test")
|
||||
expect(titleElement).toBeDefined()
|
||||
|
||||
const subTitleElement = await screen.findByText("Subtitle Test")
|
||||
expect(subTitleElement).toBeDefined()
|
||||
})
|
||||
|
||||
it("renders button if specified", async () => {
|
||||
// When
|
||||
render(<Header title="Title" action={{ text: "Button Test" }} />)
|
||||
|
||||
// Then
|
||||
const buttonElement = await screen.findByRole("button")
|
||||
expect(buttonElement).toBeDefined()
|
||||
expect(buttonElement.textContent).toEqual("Button Test")
|
||||
})
|
||||
})
|
||||
@@ -1,118 +0,0 @@
|
||||
import Box from "@material-ui/core/Box"
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import Typography from "@material-ui/core/Typography"
|
||||
import React from "react"
|
||||
import { maxWidth, sidePadding } from "../../theme/constants"
|
||||
import { HeaderButton } from "../HeaderButton/HeaderButton"
|
||||
|
||||
export interface HeaderAction {
|
||||
readonly text: string
|
||||
readonly onClick?: (event: MouseEvent) => void
|
||||
}
|
||||
|
||||
export interface HeaderProps {
|
||||
description?: string
|
||||
title: string
|
||||
subTitle?: string
|
||||
action?: HeaderAction
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({ description, title, subTitle, action }) => {
|
||||
const styles = useStyles()
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.top}>
|
||||
<div className={styles.topInner}>
|
||||
<Box display="flex" flexDirection="column" minWidth={0}>
|
||||
<div>
|
||||
<Box display="flex" alignItems="center">
|
||||
<Typography variant="h3" className={styles.title}>
|
||||
<Box component="span" maxWidth="100%" overflow="hidden" textOverflow="ellipsis">
|
||||
{title}
|
||||
</Box>
|
||||
</Typography>
|
||||
|
||||
{subTitle && (
|
||||
<div className={styles.subtitle}>
|
||||
<Typography style={{ fontSize: 16 }}>{subTitle}</Typography>
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
{description && (
|
||||
<Typography variant="caption" className={styles.description}>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{action && (
|
||||
<>
|
||||
<div className={styles.actions}>
|
||||
<HeaderButton key={action.text} {...action} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const secondaryText = "#B5BFD2"
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {},
|
||||
top: {
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
height: 126,
|
||||
background: theme.palette.background.default,
|
||||
boxShadow: theme.shadows[3],
|
||||
},
|
||||
topInner: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
maxWidth,
|
||||
margin: "0 auto",
|
||||
flex: 1,
|
||||
height: 68,
|
||||
minWidth: 0,
|
||||
padding: `0 ${sidePadding}`,
|
||||
},
|
||||
title: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
fontWeight: "bold",
|
||||
whiteSpace: "nowrap",
|
||||
minWidth: 0,
|
||||
color: theme.palette.primary.contrastText,
|
||||
},
|
||||
description: {
|
||||
display: "block",
|
||||
marginTop: theme.spacing(1) / 2,
|
||||
marginBottom: -26,
|
||||
color: secondaryText,
|
||||
},
|
||||
subtitle: {
|
||||
position: "relative",
|
||||
top: 2,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
borderLeft: `1px solid ${theme.palette.divider}`,
|
||||
height: 28,
|
||||
marginLeft: 16,
|
||||
paddingLeft: 16,
|
||||
color: secondaryText,
|
||||
},
|
||||
actions: {
|
||||
paddingLeft: "50px",
|
||||
paddingRight: 0,
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
},
|
||||
}))
|
||||
@@ -1,35 +0,0 @@
|
||||
import Button from "@material-ui/core/Button"
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import React from "react"
|
||||
|
||||
export interface HeaderButtonProps {
|
||||
readonly text: string
|
||||
readonly disabled?: boolean
|
||||
readonly onClick?: (event: MouseEvent) => void
|
||||
}
|
||||
|
||||
export const HeaderButton: React.FC<HeaderButtonProps> = (props) => {
|
||||
const styles = useStyles()
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={styles.pageButton}
|
||||
variant="contained"
|
||||
onClick={(event: React.MouseEvent): void => {
|
||||
if (props.onClick) {
|
||||
props.onClick(event.nativeEvent)
|
||||
}
|
||||
}}
|
||||
disabled={props.disabled}
|
||||
component="button"
|
||||
>
|
||||
{props.text}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles(() => ({
|
||||
pageButton: {
|
||||
whiteSpace: "nowrap",
|
||||
},
|
||||
}))
|
||||
@@ -55,5 +55,10 @@ const useStyles = makeStyles((theme: Theme) => ({
|
||||
// Set a fixed width for the select. It avoids selects having different sizes
|
||||
// depending on how many roles they have selected.
|
||||
width: theme.spacing(25),
|
||||
"& .MuiSelect-root": {
|
||||
// Adjusting padding because it does not have label
|
||||
paddingTop: theme.spacing(1.5),
|
||||
paddingBottom: theme.spacing(1.5),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -6,11 +6,11 @@ import TableHead from "@material-ui/core/TableHead"
|
||||
import TableRow from "@material-ui/core/TableRow"
|
||||
import React from "react"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import { AvatarData } from "../AvatarData/AvatarData"
|
||||
import { EmptyState } from "../EmptyState/EmptyState"
|
||||
import { RoleSelect } from "../RoleSelect/RoleSelect"
|
||||
import { TableLoader } from "../TableLoader/TableLoader"
|
||||
import { TableRowMenu } from "../TableRowMenu/TableRowMenu"
|
||||
import { UserCell } from "../UserCell/UserCell"
|
||||
|
||||
export const Language = {
|
||||
pageTitle: "Users",
|
||||
@@ -60,7 +60,7 @@ export const UsersTable: React.FC<UsersTableProps> = ({
|
||||
users.map((u) => (
|
||||
<TableRow key={u.id}>
|
||||
<TableCell>
|
||||
<UserCell Avatar={{ username: u.username }} primaryText={u.username} caption={u.email} />{" "}
|
||||
<AvatarData title={u.username} subtitle={u.email} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{canEditUsers ? (
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import Avatar from "@material-ui/core/Avatar"
|
||||
import Box from "@material-ui/core/Box"
|
||||
import Link from "@material-ui/core/Link"
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import Table from "@material-ui/core/Table"
|
||||
@@ -12,10 +10,10 @@ import relativeTime from "dayjs/plugin/relativeTime"
|
||||
import React from "react"
|
||||
import { Link as RouterLink } from "react-router-dom"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import { AvatarData } from "../../components/AvatarData/AvatarData"
|
||||
import { Margins } from "../../components/Margins/Margins"
|
||||
import { Stack } from "../../components/Stack/Stack"
|
||||
import { TableLoader } from "../../components/TableLoader/TableLoader"
|
||||
import { firstLetter } from "../../util/firstLetter"
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
@@ -73,15 +71,11 @@ export const TemplatesPageView: React.FC<TemplatesPageViewProps> = (props) => {
|
||||
{props.templates?.map((template) => (
|
||||
<TableRow key={template.id}>
|
||||
<TableCell>
|
||||
<Box alignItems="center" display="flex">
|
||||
<Avatar variant="square" className={styles.templateAvatar}>
|
||||
{firstLetter(template.name)}
|
||||
</Avatar>
|
||||
<Link component={RouterLink} to={`/templates/${template.name}`} className={styles.templateLink}>
|
||||
<b>{template.name}</b>
|
||||
<span>{template.description}</span>
|
||||
</Link>
|
||||
</Box>
|
||||
<AvatarData
|
||||
title={template.name}
|
||||
subtitle={template.description}
|
||||
link={`/templates/${template.name}`}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>{Language.developerCount(template.workspace_owner_count)}</TableCell>
|
||||
@@ -114,24 +108,4 @@ const useStyles = makeStyles((theme) => ({
|
||||
lineHeight: `${theme.spacing(3)}px`,
|
||||
},
|
||||
},
|
||||
templateAvatar: {
|
||||
borderRadius: 2,
|
||||
marginRight: theme.spacing(1),
|
||||
width: 24,
|
||||
height: 24,
|
||||
fontSize: 16,
|
||||
},
|
||||
templateLink: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
color: theme.palette.text.primary,
|
||||
textDecoration: "none",
|
||||
"&:hover": {
|
||||
textDecoration: "underline",
|
||||
},
|
||||
"& span": {
|
||||
fontSize: 12,
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -103,13 +103,13 @@ describe("Users Page", () => {
|
||||
expect(users.length).toEqual(2)
|
||||
})
|
||||
|
||||
it("shows 'New user' button to an authorized user", () => {
|
||||
it("shows 'Create user' button to an authorized user", () => {
|
||||
render(<UsersPage />)
|
||||
const newUserButton = screen.queryByText(UsersViewLanguage.newUserButton)
|
||||
expect(newUserButton).toBeDefined()
|
||||
const createUserButton = screen.queryByText(UsersViewLanguage.createButton)
|
||||
expect(createUserButton).toBeDefined()
|
||||
})
|
||||
|
||||
it("does not show 'New user' button to unauthorized user", () => {
|
||||
it("does not show 'Create user' button to unauthorized user", () => {
|
||||
server.use(
|
||||
rest.post("/api/v2/users/:userId/authorization", async (req, res, ctx) => {
|
||||
const permissions = Object.keys(permissionsToCheck)
|
||||
@@ -125,8 +125,8 @@ describe("Users Page", () => {
|
||||
}),
|
||||
)
|
||||
render(<UsersPage />)
|
||||
const newUserButton = screen.queryByText(UsersViewLanguage.newUserButton)
|
||||
expect(newUserButton).toBeNull()
|
||||
const createUserButton = screen.queryByText(UsersViewLanguage.createButton)
|
||||
expect(createUserButton).toBeNull()
|
||||
})
|
||||
|
||||
describe("suspend user", () => {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import Button from "@material-ui/core/Button"
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import AddCircleOutline from "@material-ui/icons/AddCircleOutline"
|
||||
import React from "react"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary"
|
||||
import { Header } from "../../components/Header/Header"
|
||||
import { Margins } from "../../components/Margins/Margins"
|
||||
import { Stack } from "../../components/Stack/Stack"
|
||||
import { UsersTable } from "../../components/UsersTable/UsersTable"
|
||||
|
||||
export const Language = {
|
||||
pageTitle: "Users",
|
||||
newUserButton: "New user",
|
||||
createButton: "New user",
|
||||
}
|
||||
|
||||
export interface UsersPageViewProps {
|
||||
@@ -38,11 +40,20 @@ export const UsersPageView: React.FC<UsersPageViewProps> = ({
|
||||
canCreateUser,
|
||||
isLoading,
|
||||
}) => {
|
||||
const newUserAction = canCreateUser ? { text: Language.newUserButton, onClick: openUserCreationDialog } : undefined
|
||||
const styles = useStyles()
|
||||
|
||||
return (
|
||||
<Stack spacing={4}>
|
||||
<Header title={Language.pageTitle} action={newUserAction} />
|
||||
<Margins>
|
||||
<div className={styles.actions}>
|
||||
<div>
|
||||
{canCreateUser && (
|
||||
<Button onClick={openUserCreationDialog} startIcon={<AddCircleOutline />}>
|
||||
{Language.createButton}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<ErrorSummary error={error} />
|
||||
) : (
|
||||
@@ -61,3 +72,16 @@ export const UsersPageView: React.FC<UsersPageViewProps> = ({
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
actions: {
|
||||
marginTop: theme.spacing(3),
|
||||
marginBottom: theme.spacing(3),
|
||||
display: "flex",
|
||||
height: theme.spacing(6),
|
||||
|
||||
"& > *": {
|
||||
marginLeft: "auto",
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -139,8 +139,12 @@ describe("Workspace Page", () => {
|
||||
it("shows the timeline build", async () => {
|
||||
await renderWorkspacePage()
|
||||
const table = await screen.findByTestId("builds-table")
|
||||
const rows = table.querySelectorAll("tbody > tr")
|
||||
expect(rows).toHaveLength(MockBuilds.length)
|
||||
|
||||
// Wait for the results to be loaded
|
||||
await waitFor(async () => {
|
||||
const rows = table.querySelectorAll("tbody > tr")
|
||||
expect(rows).toHaveLength(MockBuilds.length)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import Avatar from "@material-ui/core/Avatar"
|
||||
import Button from "@material-ui/core/Button"
|
||||
import Link from "@material-ui/core/Link"
|
||||
import { makeStyles, Theme } from "@material-ui/core/styles"
|
||||
@@ -14,9 +13,9 @@ import relativeTime from "dayjs/plugin/relativeTime"
|
||||
import React from "react"
|
||||
import { Link as RouterLink } from "react-router-dom"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import { AvatarData } from "../../components/AvatarData/AvatarData"
|
||||
import { Margins } from "../../components/Margins/Margins"
|
||||
import { Stack } from "../../components/Stack/Stack"
|
||||
import { firstLetter } from "../../util/firstLetter"
|
||||
import { getDisplayStatus } from "../../util/workspace"
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
@@ -73,15 +72,11 @@ export const WorkspacesPageView: React.FC<WorkspacesPageViewProps> = (props) =>
|
||||
return (
|
||||
<TableRow key={workspace.id}>
|
||||
<TableCell>
|
||||
<div className={styles.workspaceName}>
|
||||
<Avatar variant="square" className={styles.workspaceAvatar}>
|
||||
{firstLetter(workspace.name)}
|
||||
</Avatar>
|
||||
<Link component={RouterLink} to={`/workspaces/${workspace.id}`} className={styles.workspaceLink}>
|
||||
<b>{workspace.name}</b>
|
||||
<span>{workspace.owner_name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
<AvatarData
|
||||
title={workspace.name}
|
||||
subtitle={workspace.owner_name}
|
||||
link={`/workspaces/${workspace.id}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{workspace.template_name}</TableCell>
|
||||
<TableCell>
|
||||
@@ -134,28 +129,4 @@ const useStyles = makeStyles((theme) => ({
|
||||
lineHeight: `${theme.spacing(3)}px`,
|
||||
},
|
||||
},
|
||||
workspaceAvatar: {
|
||||
borderRadius: 2,
|
||||
marginRight: theme.spacing(1),
|
||||
width: 24,
|
||||
height: 24,
|
||||
fontSize: 16,
|
||||
},
|
||||
workspaceName: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
workspaceLink: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
color: theme.palette.text.primary,
|
||||
textDecoration: "none",
|
||||
"&:hover": {
|
||||
textDecoration: "underline",
|
||||
},
|
||||
"& span": {
|
||||
fontSize: 12,
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user