mirror of https://github.com/coder/coder.git
refactor(site): improve the overall user table design (#9342)
This commit is contained in:
parent
14f769d229
commit
0a213a6ac3
|
@ -37,7 +37,7 @@ const Option: React.FC<{
|
|||
onChange(e.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
<Stack spacing={0.5}>
|
||||
<Stack spacing={0}>
|
||||
<strong>{name}</strong>
|
||||
<span className={styles.optionDescription}>{description}</span>
|
||||
</Stack>
|
||||
|
@ -142,7 +142,7 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
|
|||
<div className={styles.footer}>
|
||||
<Stack direction="row" alignItems="flex-start">
|
||||
<UserIcon className={styles.userIcon} />
|
||||
<Stack spacing={0.5}>
|
||||
<Stack spacing={0}>
|
||||
<strong>{t("member")}</strong>
|
||||
<span className={styles.optionDescription}>
|
||||
{t("roleDescription.member")}
|
||||
|
@ -182,7 +182,7 @@ const useStyles = makeStyles((theme) => ({
|
|||
padding: 0,
|
||||
|
||||
"&:disabled": {
|
||||
opacity: 0.5,
|
||||
opacity: 0,
|
||||
},
|
||||
},
|
||||
options: {
|
||||
|
@ -190,6 +190,7 @@ const useStyles = makeStyles((theme) => ({
|
|||
},
|
||||
option: {
|
||||
cursor: "pointer",
|
||||
fontSize: 14,
|
||||
},
|
||||
checkbox: {
|
||||
padding: 0,
|
||||
|
@ -202,13 +203,15 @@ const useStyles = makeStyles((theme) => ({
|
|||
},
|
||||
},
|
||||
optionDescription: {
|
||||
fontSize: 12,
|
||||
fontSize: 13,
|
||||
color: theme.palette.text.secondary,
|
||||
lineHeight: "160%",
|
||||
},
|
||||
footer: {
|
||||
padding: theme.spacing(3),
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
borderTop: `1px solid ${theme.palette.divider}`,
|
||||
fontSize: 14,
|
||||
},
|
||||
userIcon: {
|
||||
width: theme.spacing(2.5), // Same as the checkbox
|
||||
|
|
|
@ -1,73 +1,80 @@
|
|||
import { ComponentMeta, Story } from "@storybook/react"
|
||||
import {
|
||||
MockUser,
|
||||
MockUser2,
|
||||
MockAssignableSiteRoles,
|
||||
MockAuthMethods,
|
||||
} from "testHelpers/entities"
|
||||
import { UsersTable, UsersTableProps } from "./UsersTable"
|
||||
import { UsersTable } from "./UsersTable"
|
||||
import type { Meta, StoryObj } from "@storybook/react"
|
||||
|
||||
export default {
|
||||
const meta: Meta<typeof UsersTable> = {
|
||||
title: "components/UsersTable",
|
||||
component: UsersTable,
|
||||
args: {
|
||||
isNonInitialPage: false,
|
||||
authMethods: MockAuthMethods,
|
||||
},
|
||||
} as ComponentMeta<typeof UsersTable>
|
||||
|
||||
const Template: Story<UsersTableProps> = (args) => <UsersTable {...args} />
|
||||
|
||||
export const Example = Template.bind({})
|
||||
Example.args = {
|
||||
users: [MockUser, MockUser2],
|
||||
roles: MockAssignableSiteRoles,
|
||||
canEditUsers: false,
|
||||
}
|
||||
|
||||
export const Editable = Template.bind({})
|
||||
Editable.args = {
|
||||
users: [
|
||||
MockUser,
|
||||
MockUser2,
|
||||
{
|
||||
...MockUser,
|
||||
username: "John Doe",
|
||||
email: "john.doe@coder.com",
|
||||
roles: [],
|
||||
status: "dormant",
|
||||
},
|
||||
{
|
||||
...MockUser,
|
||||
username: "Roger Moore",
|
||||
email: "roger.moore@coder.com",
|
||||
roles: [],
|
||||
status: "suspended",
|
||||
},
|
||||
{
|
||||
...MockUser,
|
||||
username: "OIDC User",
|
||||
email: "oidc.user@coder.com",
|
||||
roles: [],
|
||||
status: "active",
|
||||
login_type: "oidc",
|
||||
},
|
||||
],
|
||||
roles: MockAssignableSiteRoles,
|
||||
canEditUsers: true,
|
||||
canViewActivity: true,
|
||||
export default meta
|
||||
type Story = StoryObj<typeof UsersTable>
|
||||
|
||||
export const Example: Story = {
|
||||
args: {
|
||||
users: [MockUser, MockUser2],
|
||||
roles: MockAssignableSiteRoles,
|
||||
canEditUsers: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const Empty = Template.bind({})
|
||||
Empty.args = {
|
||||
users: [],
|
||||
roles: MockAssignableSiteRoles,
|
||||
export const Editable: Story = {
|
||||
args: {
|
||||
users: [
|
||||
MockUser,
|
||||
MockUser2,
|
||||
{
|
||||
...MockUser,
|
||||
username: "John Doe",
|
||||
email: "john.doe@coder.com",
|
||||
roles: [],
|
||||
status: "dormant",
|
||||
},
|
||||
{
|
||||
...MockUser,
|
||||
username: "Roger Moore",
|
||||
email: "roger.moore@coder.com",
|
||||
roles: [],
|
||||
status: "suspended",
|
||||
},
|
||||
{
|
||||
...MockUser,
|
||||
username: "OIDC User",
|
||||
email: "oidc.user@coder.com",
|
||||
roles: [],
|
||||
status: "active",
|
||||
login_type: "oidc",
|
||||
},
|
||||
],
|
||||
roles: MockAssignableSiteRoles,
|
||||
canEditUsers: true,
|
||||
canViewActivity: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const Loading = Template.bind({})
|
||||
Loading.args = {
|
||||
users: [],
|
||||
roles: MockAssignableSiteRoles,
|
||||
isLoading: true,
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
users: [],
|
||||
roles: MockAssignableSiteRoles,
|
||||
},
|
||||
}
|
||||
Loading.parameters = {
|
||||
chromatic: { pauseAnimationAtEnd: true },
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
users: [],
|
||||
roles: MockAssignableSiteRoles,
|
||||
isLoading: true,
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { pauseAnimationAtEnd: true },
|
||||
},
|
||||
}
|
||||
|
|
|
@ -38,6 +38,7 @@ export interface UsersTableProps {
|
|||
isNonInitialPage: boolean
|
||||
actorID: string
|
||||
oidcRoleSyncEnabled: boolean
|
||||
authMethods?: TypesGen.AuthMethods
|
||||
}
|
||||
|
||||
export const UsersTable: FC<React.PropsWithChildren<UsersTableProps>> = ({
|
||||
|
@ -57,6 +58,7 @@ export const UsersTable: FC<React.PropsWithChildren<UsersTableProps>> = ({
|
|||
isNonInitialPage,
|
||||
actorID,
|
||||
oidcRoleSyncEnabled,
|
||||
authMethods,
|
||||
}) => {
|
||||
return (
|
||||
<TableContainer>
|
||||
|
@ -70,10 +72,8 @@ export const UsersTable: FC<React.PropsWithChildren<UsersTableProps>> = ({
|
|||
<UserRoleHelpTooltip />
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell width="10%">{Language.loginTypeLabel}</TableCell>
|
||||
<TableCell width="10%">{Language.statusLabel}</TableCell>
|
||||
<TableCell width="10%">{Language.lastSeenLabel}</TableCell>
|
||||
|
||||
<TableCell width="15%">{Language.loginTypeLabel}</TableCell>
|
||||
<TableCell width="15%">{Language.statusLabel}</TableCell>
|
||||
{/* 1% is a trick to make the table cell width fit the content */}
|
||||
{canEditUsers && <TableCell width="1%" />}
|
||||
</TableRow>
|
||||
|
@ -96,6 +96,7 @@ export const UsersTable: FC<React.PropsWithChildren<UsersTableProps>> = ({
|
|||
isNonInitialPage={isNonInitialPage}
|
||||
actorID={actorID}
|
||||
oidcRoleSyncEnabled={oidcRoleSyncEnabled}
|
||||
authMethods={authMethods}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import Box from "@mui/material/Box"
|
||||
import { makeStyles } from "@mui/styles"
|
||||
import Box, { BoxProps } from "@mui/material/Box"
|
||||
import { makeStyles, useTheme } from "@mui/styles"
|
||||
import TableCell from "@mui/material/TableCell"
|
||||
import TableRow from "@mui/material/TableRow"
|
||||
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
|
||||
import { LastUsed } from "components/LastUsed/LastUsed"
|
||||
import { Pill } from "components/Pill/Pill"
|
||||
import { FC } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
@ -16,6 +15,16 @@ import { TableRowMenu } from "../TableRowMenu/TableRowMenu"
|
|||
import { EditRolesButton } from "components/EditRolesButton/EditRolesButton"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { EnterpriseBadge } from "components/DeploySettingsLayout/Badges"
|
||||
import dayjs from "dayjs"
|
||||
import { SxProps, Theme } from "@mui/material/styles"
|
||||
import HideSourceOutlined from "@mui/icons-material/HideSourceOutlined"
|
||||
import KeyOutlined from "@mui/icons-material/KeyOutlined"
|
||||
import GitHub from "@mui/icons-material/GitHub"
|
||||
import PasswordOutlined from "@mui/icons-material/PasswordOutlined"
|
||||
import relativeTime from "dayjs/plugin/relativeTime"
|
||||
import ShieldOutlined from "@mui/icons-material/ShieldOutlined"
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
const isOwnerRole = (role: TypesGen.Role): boolean => {
|
||||
return role.name === "owner"
|
||||
|
@ -31,6 +40,7 @@ const sortRoles = (roles: TypesGen.Role[]) => {
|
|||
|
||||
interface UsersTableBodyProps {
|
||||
users?: TypesGen.User[]
|
||||
authMethods?: TypesGen.AuthMethods
|
||||
roles?: TypesGen.AssignableRoles[]
|
||||
isUpdatingUserRoles?: boolean
|
||||
canEditUsers?: boolean
|
||||
|
@ -58,6 +68,7 @@ export const UsersTableBody: FC<
|
|||
React.PropsWithChildren<UsersTableBodyProps>
|
||||
> = ({
|
||||
users,
|
||||
authMethods,
|
||||
roles,
|
||||
onSuspendUser,
|
||||
onDeleteUser,
|
||||
|
@ -80,7 +91,7 @@ export const UsersTableBody: FC<
|
|||
return (
|
||||
<ChooseOne>
|
||||
<Cond condition={Boolean(isLoading)}>
|
||||
<TableLoaderSkeleton columns={5} useAvatarData />
|
||||
<TableLoaderSkeleton columns={canEditUsers ? 5 : 4} useAvatarData />
|
||||
</Cond>
|
||||
<Cond condition={!users || users.length === 0}>
|
||||
<ChooseOne>
|
||||
|
@ -156,7 +167,10 @@ export const UsersTableBody: FC<
|
|||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<pre>{user.login_type}</pre>
|
||||
<LoginType
|
||||
authMethods={authMethods!}
|
||||
value={user.login_type}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={combineClasses([
|
||||
|
@ -166,11 +180,10 @@ export const UsersTableBody: FC<
|
|||
: undefined,
|
||||
])}
|
||||
>
|
||||
{user.status}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<LastUsed lastUsedAt={user.last_seen_at} />
|
||||
<Box>{user.status}</Box>
|
||||
<LastSeen value={user.last_seen_at} sx={{ fontSize: 12 }} />
|
||||
</TableCell>
|
||||
|
||||
{canEditUsers && (
|
||||
<TableCell>
|
||||
<TableRowMenu
|
||||
|
@ -236,6 +249,88 @@ export const UsersTableBody: FC<
|
|||
)
|
||||
}
|
||||
|
||||
const LoginType = ({
|
||||
authMethods,
|
||||
value,
|
||||
}: {
|
||||
authMethods: TypesGen.AuthMethods
|
||||
value: TypesGen.LoginType
|
||||
}) => {
|
||||
let displayName = value as string
|
||||
let icon = <></>
|
||||
const iconStyles: SxProps = { width: 14, height: 14 }
|
||||
|
||||
if (value === "password") {
|
||||
displayName = "Password"
|
||||
icon = <PasswordOutlined sx={iconStyles} />
|
||||
} else if (value === "none") {
|
||||
displayName = "None"
|
||||
icon = <HideSourceOutlined sx={iconStyles} />
|
||||
} else if (value === "github") {
|
||||
displayName = "GitHub"
|
||||
icon = <GitHub sx={iconStyles} />
|
||||
} else if (value === "token") {
|
||||
displayName = "Token"
|
||||
icon = <KeyOutlined sx={iconStyles} />
|
||||
} else if (value === "oidc") {
|
||||
displayName =
|
||||
authMethods.oidc.signInText === "" ? "OIDC" : authMethods.oidc.signInText
|
||||
icon =
|
||||
authMethods.oidc.iconUrl === "" ? (
|
||||
<ShieldOutlined sx={iconStyles} />
|
||||
) : (
|
||||
<Box
|
||||
component="img"
|
||||
alt="Open ID Connect icon"
|
||||
src={authMethods.oidc.iconUrl}
|
||||
sx={iconStyles}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, fontSize: 14 }}>
|
||||
{icon}
|
||||
{displayName}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const LastSeen = ({ value, ...boxProps }: { value: string } & BoxProps) => {
|
||||
const theme: Theme = useTheme()
|
||||
const t = dayjs(value)
|
||||
const now = dayjs()
|
||||
|
||||
let message = t.fromNow()
|
||||
let color = theme.palette.text.secondary
|
||||
|
||||
if (t.isAfter(now.subtract(1, "hour"))) {
|
||||
color = theme.palette.success.light
|
||||
// Since the agent reports on a 10m interval,
|
||||
// the last_used_at can be inaccurate when recent.
|
||||
message = "Now"
|
||||
} else if (t.isAfter(now.subtract(3, "day"))) {
|
||||
color = theme.palette.text.secondary
|
||||
} else if (t.isAfter(now.subtract(1, "month"))) {
|
||||
color = theme.palette.warning.light
|
||||
} else if (t.isAfter(now.subtract(100, "year"))) {
|
||||
color = theme.palette.error.light
|
||||
} else {
|
||||
message = "Never"
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="span"
|
||||
data-chromatic="ignore"
|
||||
{...boxProps}
|
||||
sx={{ color, ...boxProps.sx }}
|
||||
>
|
||||
{message}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
status: {
|
||||
textTransform: "capitalize",
|
||||
|
|
|
@ -20,6 +20,8 @@ import { useStatusFilterMenu } from "./UsersFilter"
|
|||
import { useFilter } from "components/Filter/filter"
|
||||
import { useDashboard } from "components/Dashboard/DashboardProvider"
|
||||
import { deploymentConfigMachine } from "xServices/deploymentConfig/deploymentConfigMachine"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getAuthMethods } from "api/api"
|
||||
|
||||
export const Language = {
|
||||
suspendDialogTitle: "Suspend user",
|
||||
|
@ -78,16 +80,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
|
|||
const oidcRoleSyncEnabled =
|
||||
viewDeploymentValues &&
|
||||
deploymentValues?.config.oidc?.user_role_field !== ""
|
||||
|
||||
// Is loading if
|
||||
// - users are loading or
|
||||
// - the user can edit the users but the roles are loading
|
||||
const isLoading =
|
||||
usersState.matches("gettingUsers") ||
|
||||
(canEditUsers && rolesState.matches("gettingRoles"))
|
||||
|
||||
const me = useMe()
|
||||
|
||||
const useFilterResult = useFilter({
|
||||
searchParamsResult,
|
||||
onUpdate: () => {
|
||||
|
@ -105,6 +98,19 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
|
|||
status: option?.value,
|
||||
}),
|
||||
})
|
||||
const authMethods = useQuery({
|
||||
queryKey: ["authMethods"],
|
||||
queryFn: () => {
|
||||
return getAuthMethods()
|
||||
},
|
||||
})
|
||||
// Is loading if
|
||||
// - users are loading or
|
||||
// - the user can edit the users but the roles are loading
|
||||
const isLoading =
|
||||
usersState.matches("gettingUsers") ||
|
||||
(canEditUsers && rolesState.matches("gettingRoles")) ||
|
||||
authMethods.isLoading
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -115,6 +121,7 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
|
|||
oidcRoleSyncEnabled={oidcRoleSyncEnabled}
|
||||
roles={roles}
|
||||
users={users}
|
||||
authMethods={authMethods.data}
|
||||
count={count}
|
||||
onListWorkspaces={(user) => {
|
||||
navigate(
|
||||
|
|
|
@ -5,6 +5,7 @@ import {
|
|||
MockUser2,
|
||||
MockAssignableSiteRoles,
|
||||
mockApiError,
|
||||
MockAuthMethods,
|
||||
} from "testHelpers/entities"
|
||||
import { UsersPageView } from "./UsersPageView"
|
||||
import { ComponentProps } from "react"
|
||||
|
@ -33,6 +34,7 @@ const meta: Meta<typeof UsersPageView> = {
|
|||
count: 2,
|
||||
canEditUsers: true,
|
||||
filterProps: defaultFilterProps,
|
||||
authMethods: MockAuthMethods,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -22,6 +22,7 @@ export interface UsersPageViewProps {
|
|||
oidcRoleSyncEnabled: boolean
|
||||
canViewActivity?: boolean
|
||||
isLoading?: boolean
|
||||
authMethods?: TypesGen.AuthMethods
|
||||
onSuspendUser: (user: TypesGen.User) => void
|
||||
onDeleteUser: (user: TypesGen.User) => void
|
||||
onListWorkspaces: (user: TypesGen.User) => void
|
||||
|
@ -58,6 +59,7 @@ export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
|
|||
paginationRef,
|
||||
isNonInitialPage,
|
||||
actorID,
|
||||
authMethods,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
|
@ -89,6 +91,7 @@ export const UsersPageView: FC<React.PropsWithChildren<UsersPageViewProps>> = ({
|
|||
isLoading={isLoading}
|
||||
isNonInitialPage={isNonInitialPage}
|
||||
actorID={actorID}
|
||||
authMethods={authMethods}
|
||||
/>
|
||||
|
||||
<PaginationWidget numRecords={count} paginationRef={paginationRef} />
|
||||
|
|
|
@ -27,7 +27,7 @@ import Box from "@mui/material/Box"
|
|||
import { AvatarData } from "components/AvatarData/AvatarData"
|
||||
import { Avatar } from "components/Avatar/Avatar"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { LastUsed } from "components/LastUsed/LastUsed"
|
||||
import { LastUsed } from "pages/WorkspacesPage/LastUsed"
|
||||
import { WorkspaceOutdatedTooltip } from "components/Tooltips"
|
||||
import { WorkspaceStatusBadge } from "components/WorkspaceStatusBadge/WorkspaceStatusBadge"
|
||||
import { getDisplayWorkspaceTemplateName } from "utils/workspace"
|
||||
|
|
Loading…
Reference in New Issue