refactor(site): improve the overall user table design (#9342)

This commit is contained in:
Bruno Quaresma 2023-08-25 14:59:41 -03:00 committed by GitHub
parent 14f769d229
commit 0a213a6ac3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 199 additions and 81 deletions

View File

@ -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

View File

@ -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 },
},
}

View File

@ -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>

View File

@ -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",

View File

@ -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(

View File

@ -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,
},
}

View File

@ -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} />

View File

@ -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"