Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

feat: Add update user roles action#1361

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.

Already on GitHub?Sign in to your account

Merged
BrunoQuaresma merged 18 commits intomainfrombq/fe/update-user-roles-action
May 10, 2022
Merged
Show file tree
Hide file tree
Changes fromall commits
Commits
Show all changes
18 commits
Select commitHold shift + click to select a range
32cba83
Load roles and show them in the table
BrunoQuaresmaMay 6, 2022
b167995
Update types
BrunoQuaresmaMay 6, 2022
cabe385
Update API
BrunoQuaresmaMay 6, 2022
b71ede5
Update roles to fetch the site roles
BrunoQuaresmaMay 6, 2022
d5e749f
Merge branch 'main' of github.com:coder/coder into bq/fe/update-user-…
BrunoQuaresmaMay 9, 2022
1e99a04
Add UI for update user roles
BrunoQuaresmaMay 9, 2022
46add41
Set fixed RoleSelect width
BrunoQuaresmaMay 9, 2022
16d1e7b
Fix UI to update user roles
BrunoQuaresmaMay 9, 2022
1e93bd2
Do not display the success notification
BrunoQuaresmaMay 9, 2022
82a60f8
Add role select storybook
BrunoQuaresmaMay 10, 2022
9c0587e
Add missing tests
BrunoQuaresmaMay 10, 2022
c7c7b95
Add fail test case
BrunoQuaresmaMay 10, 2022
a01cb87
Update site/src/components/RoleSelect/RoleSelect.tsx
BrunoQuaresmaMay 10, 2022
f402da0
Update site/src/components/RoleSelect/RoleSelect.tsx
BrunoQuaresmaMay 10, 2022
9f47873
Update site/src/pages/UsersPage/UsersPage.test.tsx
BrunoQuaresmaMay 10, 2022
6cf12b5
Clear get roles error
BrunoQuaresmaMay 10, 2022
16c3fe3
Fix storybook
BrunoQuaresmaMay 10, 2022
9bd6ca5
Add comment about fetch the roles
BrunoQuaresmaMay 10, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletionssite/src/api/index.ts
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -158,3 +158,16 @@ export const suspendUser = async (userId: TypesGen.User["id"]): Promise<TypesGen

export const updateUserPassword = async (password: string, userId: TypesGen.User["id"]): Promise<undefined> =>
axios.put(`/api/v2/users/${userId}/password`, { password })

export const getSiteRoles = async (): Promise<Array<TypesGen.Role>> => {
const response = await axios.get<Array<TypesGen.Role>>(`/api/v2/users/roles`)
return response.data
}

export const updateUserRoles = async (
roles: TypesGen.Role["name"][],
userId: TypesGen.User["id"],
): Promise<TypesGen.User> => {
const response = await axios.put<TypesGen.User>(`/api/v2/users/${userId}/roles`, { roles })
return response.data
}
24 changes: 24 additions & 0 deletionssite/src/components/RoleSelect/RoleSelect.stories.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
import { ComponentMeta, Story } from "@storybook/react"
import React from "react"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

@BrunoQuaresma Just looking at merged PRs to learn - no action necessary here. Curious if there's a reason we're importing React given we're on version 17 instead ofsilencing the 'React must be in scope' warnings.

Copy link
CollaboratorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others.Learn more.

Good question, I think it is automatically added by an eslint role, but I'm not 100% sure.

import { MockAdminRole, MockMemberRole, MockSiteRoles } from "../../testHelpers"
import { RoleSelect, RoleSelectProps } from "./RoleSelect"

export default {
title: "components/RoleSelect",
component: RoleSelect,
} as ComponentMeta<typeof RoleSelect>

const Template: Story<RoleSelectProps> = (args) => <RoleSelect {...args} />

export const Close = Template.bind({})
Close.args = {
roles: MockSiteRoles,
selectedRoles: [MockAdminRole, MockMemberRole],
}

export const Open = Template.bind({})
Open.args = {
open: true,
roles: MockSiteRoles,
selectedRoles: [MockAdminRole, MockMemberRole],
}
59 changes: 59 additions & 0 deletionssite/src/components/RoleSelect/RoleSelect.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
import Checkbox from "@material-ui/core/Checkbox"
import MenuItem from "@material-ui/core/MenuItem"
import Select from "@material-ui/core/Select"
import { makeStyles, Theme } from "@material-ui/core/styles"
import React from "react"
import { Role } from "../../api/typesGenerated"

export const Language = {
label: "Roles",
}
export interface RoleSelectProps {
roles: Role[]
selectedRoles: Role[]
onChange: (roles: Role["name"][]) => void
loading?: boolean
open?: boolean
}

export const RoleSelect: React.FC<RoleSelectProps> = ({ roles, selectedRoles, loading, onChange, open }) => {
const styles = useStyles()
const value = selectedRoles.map((r) => r.name)
const renderValue = () => selectedRoles.map((r) => r.display_name).join(", ")
const sortedRoles = roles.sort((a, b) => a.display_name.localeCompare(b.display_name))

return (
<Select
aria-label={Language.label}
open={open}
multiple
value={value}
renderValue={renderValue}
variant="outlined"
className={styles.select}
onChange={(e) => {
const { value } = e.target
onChange(value as string[])
}}
>
{sortedRoles.map((r) => {
const isChecked = selectedRoles.some((selectedRole) => selectedRole.name === r.name)

return (
<MenuItem key={r.name} value={r.name} disabled={loading}>
<Checkbox color="primary" checked={isChecked} /> {r.display_name}
</MenuItem>
)
})}
</Select>
)
}

const useStyles = makeStyles((theme: Theme) => ({
select: {
margin: 0,
// 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),
},
}))
10 changes: 7 additions & 3 deletionssite/src/components/TableHeaders/TableHeaders.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,18 +8,22 @@ export interface TableHeadersProps {
hasMenu?: boolean
}

export constTableHeaders: React.FC<TableHeadersProps> = ({columns, hasMenu }) => {
export constTableHeaderRow: React.FC = ({children }) => {
const styles = useStyles()
return <TableRow className={styles.root}>{children}</TableRow>
}

export const TableHeaders: React.FC<TableHeadersProps> = ({ columns, hasMenu }) => {
return (
<TableRow className={styles.root}>
<TableHeaderRow>
{columns.map((c, idx) => (
<TableCell key={idx} size="small">
{c}
</TableCell>
))}
{/* 1% is a trick to make the table cell width fit the content */}
{hasMenu && <TableCell width="1%" />}
</TableRow>
</TableHeaderRow>
)
}

Expand Down
4 changes: 3 additions & 1 deletionsite/src/components/UsersTable/UsersTable.stories.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { ComponentMeta, Story } from "@storybook/react"
import React from "react"
import { MockUser, MockUser2 } from "../../testHelpers"
import {MockSiteRoles,MockUser, MockUser2 } from "../../testHelpers"
import { UsersTable, UsersTableProps } from "./UsersTable"

export default {
Expand All@@ -13,9 +13,11 @@ const Template: Story<UsersTableProps> = (args) => <UsersTable {...args} />
export const Example = Template.bind({})
Example.args = {
users: [MockUser, MockUser2],
roles: MockSiteRoles,
}

export const Empty = Template.bind({})
Empty.args = {
users: [],
roles: MockSiteRoles,
}
110 changes: 75 additions & 35 deletionssite/src/components/UsersTable/UsersTable.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
import Box from "@material-ui/core/Box"
import Table from "@material-ui/core/Table"
import TableBody from "@material-ui/core/TableBody"
import TableCell from "@material-ui/core/TableCell"
import TableHead from "@material-ui/core/TableHead"
import TableRow from "@material-ui/core/TableRow"
import React from "react"
import { UserResponse } from "../../api/types"
import * as TypesGen from "../../api/typesGenerated"
import { EmptyState } from "../EmptyState/EmptyState"
import { Column, Table } from "../Table/Table"
import { RoleSelect } from "../RoleSelect/RoleSelect"
import { TableHeaderRow } from "../TableHeaders/TableHeaders"
import { TableRowMenu } from "../TableRowMenu/TableRowMenu"
import { TableTitle } from "../TableTitle/TableTitle"
import { UserCell } from "../UserCell/UserCell"

export const Language = {
Expand All@@ -12,48 +21,79 @@ export const Language = {
usernameLabel: "User",
suspendMenuItem: "Suspend",
resetPasswordMenuItem: "Reset password",
rolesLabel: "Roles",
}

const emptyState = <EmptyState message={Language.emptyMessage} />

const columns: Column<UserResponse>[] = [
{
key: "username",
name: Language.usernameLabel,
renderer: (field, data) => {
return <UserCell Avatar={{ username: data.username }} primaryText={data.username} caption={data.email} />
},
},
]

export interface UsersTableProps {
users: UserResponse[]
onSuspendUser: (user: UserResponse) => void
onResetUserPassword: (user: UserResponse) => void
onUpdateUserRoles: (user: UserResponse, roles: TypesGen.Role["name"][]) => void
roles: TypesGen.Role[]
isUpdatingUserRoles?: boolean
}

export const UsersTable: React.FC<UsersTableProps> = ({ users, onSuspendUser, onResetUserPassword }) => {
export const UsersTable: React.FC<UsersTableProps> = ({
users,
roles,
onSuspendUser,
onResetUserPassword,
onUpdateUserRoles,
isUpdatingUserRoles,
}) => {
return (
<Table
columns={columns}
data={users}
title={Language.usersTitle}
emptyState={emptyState}
rowMenu={(user) => (
<TableRowMenu
data={user}
menuItems={[
{
label: Language.suspendMenuItem,
onClick: onSuspendUser,
},
{
label: Language.resetPasswordMenuItem,
onClick: onResetUserPassword,
},
]}
/>
)}
/>
<Table>
<TableHead>
<TableTitle title={Language.usersTitle} />
<TableHeaderRow>
<TableCell size="small">{Language.usernameLabel}</TableCell>
<TableCell size="small">{Language.rolesLabel}</TableCell>
{/* 1% is a trick to make the table cell width fit the content */}
<TableCell size="small" width="1%" />
</TableHeaderRow>
</TableHead>
<TableBody>
{users.map((u) => (
<TableRow key={u.id}>
<TableCell>
<UserCell Avatar={{ username: u.username }} primaryText={u.username} caption={u.email} />{" "}
</TableCell>
<TableCell>
<RoleSelect
roles={roles}
selectedRoles={u.roles}
loading={isUpdatingUserRoles}
onChange={(roles) => onUpdateUserRoles(u, roles)}
/>
</TableCell>
<TableCell>
<TableRowMenu
data={u}
menuItems={[
{
label: Language.suspendMenuItem,
onClick: onSuspendUser,
},
{
label: Language.resetPasswordMenuItem,
onClick: onResetUserPassword,
},
]}
/>
</TableCell>
</TableRow>
))}

{users.length === 0 && (
<TableRow>
<TableCell colSpan={999}>
<Box p={4}>
<EmptyState message={Language.emptyMessage} />
</Box>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)
}
83 changes: 82 additions & 1 deletionsite/src/pages/UsersPage/UsersPage.test.tsx
View file
Open in desktop
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react"
import React from "react"
import * as API from "../../api"
import { Role } from "../../api/typesGenerated"
import { GlobalSnackbar } from "../../components/GlobalSnackbar/GlobalSnackbar"
import { Language as ResetPasswordDialogLanguage } from "../../components/ResetPasswordDialog/ResetPasswordDialog"
import { Language as RoleSelectLanguage } from "../../components/RoleSelect/RoleSelect"
import { Language as UsersTableLanguage } from "../../components/UsersTable/UsersTable"
import { MockUser, MockUser2, render } from "../../testHelpers"
import {MockAuditorRole,MockUser, MockUser2, render } from "../../testHelpers"
import { Language as usersXServiceLanguage } from "../../xServices/users/usersXService"
import { Language as UsersPageLanguage, UsersPage } from "./UsersPage"

Expand DownExpand Up@@ -62,6 +64,34 @@ const resetUserPassword = async (setupActionSpies: () => void) => {
fireEvent.click(confirmButton)
}

const updateUserRole = async (setupActionSpies: () => void, role: Role) => {
// Get the first user in the table
const users = await screen.findAllByText(/.*@coder.com/)
const firstUserRow = users[0].closest("tr")
if (!firstUserRow) {
throw new Error("Error on get the first user row")
}

// Click on the "roles" menu to display the role options
const rolesLabel = within(firstUserRow).getByLabelText(RoleSelectLanguage.label)
const rolesMenuTrigger = within(rolesLabel).getByRole("button")
// For MUI v4, the Select was changed to open on mouseDown instead of click
// https://github.com/mui-org/material-ui/pull/17978
fireEvent.mouseDown(rolesMenuTrigger)

// Setup spies to check the actions after
setupActionSpies()

// Click on the role option
const listBox = screen.getByRole("listbox")
const auditorOption = within(listBox).getByRole("option", { name: role.display_name })
fireEvent.click(auditorOption)

return {
rolesMenuTrigger,
}
}

describe("Users Page", () => {
it("shows users", async () => {
render(<UsersPage />)
Expand DownExpand Up@@ -164,4 +194,55 @@ describe("Users Page", () => {
})
})
})

describe("Update user role", () => {
describe("when it is success", () => {
it("updates the roles", async () => {
render(
<>
<UsersPage />
<GlobalSnackbar />
</>,
)

const { rolesMenuTrigger } = await updateUserRole(() => {
jest.spyOn(API, "updateUserRoles").mockResolvedValueOnce({
...MockUser,
roles: [...MockUser.roles, MockAuditorRole],
})
}, MockAuditorRole)

// Check if the select text was updated with the Auditor role
await waitFor(() => expect(rolesMenuTrigger).toHaveTextContent("Admin, Member, Auditor"))

// Check if the API was called correctly
const currentRoles = MockUser.roles.map((r) => r.name)
expect(API.updateUserRoles).toBeCalledTimes(1)
expect(API.updateUserRoles).toBeCalledWith([...currentRoles, MockAuditorRole.name], MockUser.id)
})
})

describe("when it fails", () => {
it("shows an error message", async () => {
render(
<>
<UsersPage />
<GlobalSnackbar />
</>,
)

await updateUserRole(() => {
jest.spyOn(API, "updateUserRoles").mockRejectedValueOnce({})
}, MockAuditorRole)

// Check if the error message is displayed
await screen.findByText(usersXServiceLanguage.updateUserRolesError)

// Check if the API was called correctly
const currentRoles = MockUser.roles.map((r) => r.name)
expect(API.updateUserRoles).toBeCalledTimes(1)
expect(API.updateUserRoles).toBeCalledWith([...currentRoles, MockAuditorRole.name], MockUser.id)
})
})
})
})
Loading

[8]ページ先頭

©2009-2025 Movatter.jp