Skip to content

Commit 3dac95a

Browse files
committed
Add index page for groups
1 parent 0e2cb22 commit 3dac95a

File tree

11 files changed

+264
-8
lines changed

11 files changed

+264
-8
lines changed

coderd/database/queries.sql.go

+3-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

site/src/AppRouter.tsx

+12
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { RequirePermission } from "components/RequirePermission/RequirePermissio
55
import { TemplateLayout } from "components/TemplateLayout/TemplateLayout"
66
import IndexPage from "pages"
77
import AuditPage from "pages/AuditPage/AuditPage"
8+
import GroupsPage from "pages/GroupsPage/GroupsPage"
89
import LoginPage from "pages/LoginPage/LoginPage"
910
import { SetupPage } from "pages/SetupPage/SetupPage"
1011
import TemplatePermissionsPage from "pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage"
@@ -149,6 +150,17 @@ export const AppRouter: FC = () => {
149150
/>
150151
</Route>
151152

153+
<Route path="/groups">
154+
<Route
155+
index
156+
element={
157+
<AuthAndFrame>
158+
<GroupsPage />
159+
</AuthAndFrame>
160+
}
161+
/>
162+
</Route>
163+
152164
<Route path="/audit">
153165
<Route
154166
index

site/src/api/api.ts

+5
Original file line numberDiff line numberDiff line change
@@ -507,3 +507,8 @@ export const getApplicationsHost = async (): Promise<TypesGen.GetAppHostResponse
507507
const response = await axios.get(`/api/v2/applications/host`)
508508
return response.data
509509
}
510+
511+
export const getGroups = async (organizationId: string): Promise<TypesGen.Group[]> => {
512+
const response = await axios.get(`/api/v2/organizations/${organizationId}/groups`)
513+
return response.data
514+
}

site/src/api/typesGenerated.ts

+20
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,11 @@ export interface CreateFirstUserResponse {
152152
readonly organization_id: string
153153
}
154154

155+
// From codersdk/groups.go
156+
export interface CreateGroupRequest {
157+
readonly name: string
158+
}
159+
155160
// From codersdk/users.go
156161
export interface CreateOrganizationRequest {
157162
readonly name: string
@@ -273,6 +278,14 @@ export interface GitSSHKey {
273278
readonly public_key: string
274279
}
275280

281+
// From codersdk/groups.go
282+
export interface Group {
283+
readonly uuid: string
284+
readonly name: string
285+
readonly organization_id: string
286+
readonly members: User[]
287+
}
288+
276289
// From codersdk/workspaceapps.go
277290
export interface Healthcheck {
278291
readonly url: string
@@ -356,6 +369,13 @@ export interface ParameterSchema {
356369
readonly validation_contains?: string[]
357370
}
358371

372+
// From codersdk/groups.go
373+
export interface PatchGroupRequest {
374+
readonly add_users: string[]
375+
readonly remove_users: string[]
376+
readonly name: string
377+
}
378+
359379
// From codersdk/provisionerdaemons.go
360380
export interface ProvisionerDaemon {
361381
readonly id: string

site/src/components/NavbarView/NavbarView.tsx

+5
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ const NavItems: React.FC<
5555
{Language.users}
5656
</NavLink>
5757
</ListItem>
58+
<ListItem button className={styles.item}>
59+
<NavLink className={styles.link} to="/groups">
60+
Groups
61+
</NavLink>
62+
</ListItem>
5863
{canViewAuditLog && (
5964
<ListItem button className={styles.item}>
6065
<NavLink className={styles.link} to="/audit">
+146
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import Button from "@material-ui/core/Button"
2+
import Link from "@material-ui/core/Link"
3+
import { makeStyles } from "@material-ui/core/styles"
4+
import Table from "@material-ui/core/Table"
5+
import TableBody from "@material-ui/core/TableBody"
6+
import TableCell from "@material-ui/core/TableCell"
7+
import TableContainer from "@material-ui/core/TableContainer"
8+
import TableHead from "@material-ui/core/TableHead"
9+
import TableRow from "@material-ui/core/TableRow"
10+
import AddCircleOutline from "@material-ui/icons/AddCircleOutline"
11+
import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight"
12+
import { useMachine } from "@xstate/react"
13+
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
14+
import { EmptyState } from "components/EmptyState/EmptyState"
15+
import { Margins } from "components/Margins/Margins"
16+
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"
17+
import { TableCellLink } from "components/TableCellLink/TableCellLink"
18+
import { TableLoader } from "components/TableLoader/TableLoader"
19+
import { useOrganizationId } from "hooks/useOrganizationId"
20+
import React from "react"
21+
import { Helmet } from "react-helmet-async"
22+
import { Link as RouterLink, useNavigate } from "react-router-dom"
23+
import { pageTitle } from "util/page"
24+
import { groupsMachine } from "xServices/groups/groupsXService"
25+
26+
const CreateGroupButton: React.FC = () => {
27+
return (
28+
<Link underline="none" component={RouterLink} to="/groups/new">
29+
<Button startIcon={<AddCircleOutline />}>Create group</Button>
30+
</Link>
31+
)
32+
}
33+
34+
export const GroupsPage: React.FC = () => {
35+
const organizationId = useOrganizationId()
36+
const [state] = useMachine(groupsMachine, {
37+
context: {
38+
organizationId,
39+
},
40+
})
41+
const { groups } = state.context
42+
const isLoading = Boolean(groups === undefined)
43+
const isEmpty = Boolean(groups && groups.length === 0)
44+
const navigate = useNavigate()
45+
const styles = useStyles()
46+
47+
return (
48+
<>
49+
<Helmet>
50+
<title>{pageTitle("Groups")}</title>
51+
</Helmet>
52+
<Margins>
53+
<PageHeader actions={<CreateGroupButton />}>
54+
<PageHeaderTitle>Groups</PageHeaderTitle>
55+
</PageHeader>
56+
<TableContainer>
57+
<Table>
58+
<TableHead>
59+
<TableRow>
60+
<TableCell width="50%">Name</TableCell>
61+
<TableCell width="49%">Users</TableCell>
62+
<TableCell width="1%"></TableCell>
63+
</TableRow>
64+
</TableHead>
65+
<TableBody>
66+
<ChooseOne>
67+
<Cond condition={isLoading}>
68+
<TableLoader />
69+
</Cond>
70+
71+
<Cond condition={isEmpty}>
72+
<TableRow>
73+
<TableCell colSpan={999}>
74+
<EmptyState
75+
message="No groups yet"
76+
description="Create your first group"
77+
cta={<CreateGroupButton />}
78+
/>
79+
</TableCell>
80+
</TableRow>
81+
</Cond>
82+
83+
<Cond condition={!isEmpty}>
84+
{groups?.map((group) => {
85+
const groupPageLink = `/groups/${group.uuid}`
86+
87+
return (
88+
<TableRow
89+
key={group.uuid}
90+
hover
91+
data-testid={`group-${group.uuid}`}
92+
tabIndex={0}
93+
onKeyDown={(event) => {
94+
if (event.key === "Enter") {
95+
navigate(groupPageLink)
96+
}
97+
}}
98+
className={styles.clickableTableRow}
99+
>
100+
<TableCellLink to={groupPageLink}>{group.name}</TableCellLink>
101+
102+
<TableCell>Users</TableCell>
103+
104+
<TableCellLink to={groupPageLink}>
105+
<div className={styles.arrowCell}>
106+
<KeyboardArrowRight className={styles.arrowRight} />
107+
</div>
108+
</TableCellLink>
109+
</TableRow>
110+
)
111+
})}
112+
</Cond>
113+
</ChooseOne>
114+
</TableBody>
115+
</Table>
116+
</TableContainer>
117+
</Margins>
118+
</>
119+
)
120+
}
121+
122+
const useStyles = makeStyles((theme) => ({
123+
clickableTableRow: {
124+
"&:hover td": {
125+
backgroundColor: theme.palette.action.hover,
126+
},
127+
128+
"&:focus": {
129+
outline: `1px solid ${theme.palette.secondary.dark}`,
130+
},
131+
132+
"& .MuiTableCell-root:last-child": {
133+
paddingRight: theme.spacing(2),
134+
},
135+
},
136+
arrowRight: {
137+
color: theme.palette.text.secondary,
138+
width: 20,
139+
height: 20,
140+
},
141+
arrowCell: {
142+
display: "flex",
143+
},
144+
}))
145+
146+
export default GroupsPage

site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPage.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { pageTitle } from "util/page"
77
import { Permissions } from "xServices/auth/authXService"
88
import { templateUsersMachine } from "xServices/template/templateUsersXService"
99
import { TemplateContext } from "xServices/template/templateXService"
10-
import { TemplateCollaboratorsPageView } from "./TemplatePermissionsPageView"
10+
import { TemplatePermissionsPageView } from "./TemplatePermissionsPageView"
1111

1212
export const TemplateCollaboratorsPage: FC<React.PropsWithChildren<unknown>> = () => {
1313
const { templateContext, permissions } = useOutletContext<{
@@ -32,9 +32,9 @@ export const TemplateCollaboratorsPage: FC<React.PropsWithChildren<unknown>> = (
3232
return (
3333
<>
3434
<Helmet>
35-
<title>{pageTitle(`${template.name} · Collaborators`)}</title>
35+
<title>{pageTitle(`${template.name} · Permissions`)}</title>
3636
</Helmet>
37-
<TemplateCollaboratorsPageView
37+
<TemplatePermissionsPageView
3838
canUpdateUsers={canUpdatesUsers}
3939
templateUsers={templateUsers}
4040
deleteTemplateError={deleteTemplateError}

site/src/pages/TemplatePage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ const AddTemplateUser: React.FC<{
146146
)
147147
}
148148

149-
export interface TemplateCollaboratorsPageViewProps {
149+
export interface TemplatePermissionsPageViewProps {
150150
deleteTemplateError: Error | unknown
151151
templateUsers: TemplateUser[] | undefined
152152
onAddUser: (user: User, role: TemplateRole, reset: () => void) => void
@@ -157,8 +157,8 @@ export interface TemplateCollaboratorsPageViewProps {
157157
onRemoveUser: (user: User) => void
158158
}
159159

160-
export const TemplateCollaboratorsPageView: FC<
161-
React.PropsWithChildren<TemplateCollaboratorsPageViewProps>
160+
export const TemplatePermissionsPageView: FC<
161+
React.PropsWithChildren<TemplatePermissionsPageViewProps>
162162
> = ({
163163
deleteTemplateError,
164164
templateUsers,

site/src/testHelpers/entities.ts

+7
Original file line numberDiff line numberDiff line change
@@ -845,3 +845,10 @@ export const MockAuditLog2: TypesGen.AuditLog = {
845845
},
846846
},
847847
}
848+
849+
export const MockGroup: TypesGen.Group = {
850+
name: "Coder Group",
851+
uuid: "53bded77-7b9d-4e82-8771-991a34d75930",
852+
organization_id: MockOrganization.id,
853+
members: [],
854+
}

site/src/testHelpers/handlers.ts

+6
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { WorkspaceBuildTransition } from "../api/types"
33
import { CreateWorkspaceBuildRequest } from "../api/typesGenerated"
44
import { permissionsToCheck } from "../xServices/auth/authXService"
55
import * as M from "./entities"
6+
import { MockGroup } from "./entities"
67

78
export const handlers = [
89
rest.get("/api/v2/templates/:templateId/daus", async (req, res, ctx) => {
@@ -173,4 +174,9 @@ export const handlers = [
173174
rest.get("/api/v2/applications/host", (req, res, ctx) => {
174175
return res(ctx.status(200), ctx.json({ host: "dev.coder.com" }))
175176
}),
177+
178+
// Groups
179+
rest.get("/api/v2/organizations/:organizationId/groups", (req, res, ctx) => {
180+
return res(ctx.status(200), ctx.json([MockGroup]))
181+
}),
176182
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { getGroups } from "api/api"
2+
import { getErrorMessage } from "api/errors"
3+
import { Group } from "api/typesGenerated"
4+
import { displayError } from "components/GlobalSnackbar/utils"
5+
import { assign, createMachine } from "xstate"
6+
7+
export const groupsMachine = createMachine(
8+
{
9+
id: "groupsMachine",
10+
schema: {
11+
context: {} as {
12+
organizationId: string
13+
groups?: Group[]
14+
},
15+
services: {} as {
16+
loadGroups: {
17+
data: Group[]
18+
}
19+
},
20+
},
21+
tsTypes: {} as import("./groupsXService.typegen").Typegen0,
22+
initial: "loading",
23+
states: {
24+
loading: {
25+
invoke: {
26+
src: "loadGroups",
27+
onDone: {
28+
actions: ["assignGroups"],
29+
target: "idle",
30+
},
31+
onError: {
32+
target: "idle",
33+
actions: ["displayLoadingGroupsError"],
34+
},
35+
},
36+
},
37+
idle: {},
38+
},
39+
},
40+
{
41+
services: {
42+
loadGroups: ({ organizationId }) => getGroups(organizationId),
43+
},
44+
actions: {
45+
assignGroups: assign({
46+
groups: (_, { data }) => data,
47+
}),
48+
displayLoadingGroupsError: (_, { data }) => {
49+
const message = getErrorMessage(data, "Error on loading groups.")
50+
displayError(message)
51+
},
52+
},
53+
},
54+
)

0 commit comments

Comments
 (0)