Skip to content

Commit 8b03e2b

Browse files
f0sselgreyscaledKira-Pilot
authored
feat: Workspaces filtering (#1972)
Co-authored-by: G r e y <grey@coder.com> Co-authored-by: Kira Pilot <kira@coder.com>
1 parent ac6cb26 commit 8b03e2b

File tree

14 files changed

+377
-121
lines changed

14 files changed

+377
-121
lines changed

coderd/database/databasefake/databasefake.go

+3
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,9 @@ func (q *fakeQuerier) GetWorkspacesWithFilter(_ context.Context, arg database.Ge
328328
if !arg.Deleted && workspace.Deleted {
329329
continue
330330
}
331+
if arg.Name != "" && workspace.Name != arg.Name {
332+
continue
333+
}
331334
workspaces = append(workspaces, workspace)
332335
}
333336

coderd/database/queries.sql.go

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

coderd/database/queries/workspaces.sql

+6
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ WHERE
2828
owner_id = @owner_id
2929
ELSE true
3030
END
31+
-- Filter by name
32+
AND CASE
33+
WHEN @name :: text != '' THEN
34+
LOWER(name) = LOWER(@name)
35+
ELSE true
36+
END
3137
;
3238

3339
-- name: GetWorkspacesByOrganizationIDs :many

coderd/workspaces.go

+10-13
Original file line numberDiff line numberDiff line change
@@ -137,17 +137,14 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
137137
// Empty strings mean no filter
138138
orgFilter := r.URL.Query().Get("organization_id")
139139
ownerFilter := r.URL.Query().Get("owner")
140+
nameFilter := r.URL.Query().Get("name")
140141

141142
filter := database.GetWorkspacesWithFilterParams{Deleted: false}
142143
if orgFilter != "" {
143144
orgID, err := uuid.Parse(orgFilter)
144-
if err != nil {
145-
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
146-
Message: fmt.Sprintf("organization_id must be a uuid: %s", err.Error()),
147-
})
148-
return
145+
if err == nil {
146+
filter.OrganizationID = orgID
149147
}
150-
filter.OrganizationID = orgID
151148
}
152149
if ownerFilter == "me" {
153150
filter.OwnerID = apiKey.UserID
@@ -160,15 +157,15 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
160157
Username: ownerFilter,
161158
Email: ownerFilter,
162159
})
163-
if err != nil {
164-
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
165-
Message: "owner must be a uuid or username",
166-
})
167-
return
160+
if err == nil {
161+
filter.OwnerID = user.ID
168162
}
169-
userID = user.ID
163+
} else {
164+
filter.OwnerID = userID
170165
}
171-
filter.OwnerID = userID
166+
}
167+
if nameFilter != "" {
168+
filter.Name = nameFilter
172169
}
173170

174171
workspaces, err := api.Database.GetWorkspacesWithFilter(r.Context(), filter)

coderd/workspaces_test.go

+31
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,37 @@ func TestWorkspacesByOwner(t *testing.T) {
268268
require.NoError(t, err)
269269
require.Len(t, workspaces, 1)
270270
})
271+
272+
t.Run("ListName", func(t *testing.T) {
273+
t.Parallel()
274+
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
275+
user := coderdtest.CreateFirstUser(t, client)
276+
277+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
278+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
279+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
280+
w := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
281+
282+
// Create noise workspace that should be filtered out
283+
_ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
284+
285+
// Use name filter
286+
workspaces, err := client.Workspaces(context.Background(), codersdk.WorkspaceFilter{
287+
Name: w.Name,
288+
})
289+
require.NoError(t, err)
290+
require.Len(t, workspaces, 1)
291+
292+
// Create same name workspace that should be included
293+
other := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
294+
_ = coderdtest.CreateWorkspace(t, other, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { cwr.Name = w.Name })
295+
296+
workspaces, err = client.Workspaces(context.Background(), codersdk.WorkspaceFilter{
297+
Name: w.Name,
298+
})
299+
require.NoError(t, err)
300+
require.Len(t, workspaces, 2)
301+
})
271302
}
272303

273304
func TestWorkspaceByOwnerAndName(t *testing.T) {

codersdk/workspaces.go

+6-2
Original file line numberDiff line numberDiff line change
@@ -198,9 +198,10 @@ func (c *Client) PutExtendWorkspace(ctx context.Context, id uuid.UUID, req PutEx
198198
}
199199

200200
type WorkspaceFilter struct {
201-
OrganizationID uuid.UUID
201+
OrganizationID uuid.UUID `json:"organization_id,omitempty"`
202202
// Owner can be a user_id (uuid), "me", or a username
203-
Owner string
203+
Owner string `json:"owner,omitempty"`
204+
Name string `json:"name,omitempty"`
204205
}
205206

206207
// asRequestOption returns a function that can be used in (*Client).Request.
@@ -214,6 +215,9 @@ func (f WorkspaceFilter) asRequestOption() requestOption {
214215
if f.Owner != "" {
215216
q.Set("owner", f.Owner)
216217
}
218+
if f.Name != "" {
219+
q.Set("name", f.Name)
220+
}
217221
r.URL.RawQuery = q.Encode()
218222
}
219223
}

site/src/api/api.test.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,10 @@ describe("api.ts", () => {
118118
it.each<[TypesGen.WorkspaceFilter | undefined, string]>([
119119
[undefined, "/api/v2/workspaces"],
120120

121-
[{ OrganizationID: "1", Owner: "" }, "/api/v2/workspaces?organization_id=1"],
122-
[{ OrganizationID: "", Owner: "1" }, "/api/v2/workspaces?owner=1"],
121+
[{ organization_id: "1", owner: "" }, "/api/v2/workspaces?organization_id=1"],
122+
[{ organization_id: "", owner: "1" }, "/api/v2/workspaces?owner=1"],
123123

124-
[{ OrganizationID: "1", Owner: "me" }, "/api/v2/workspaces?organization_id=1&owner=me"],
124+
[{ organization_id: "1", owner: "me" }, "/api/v2/workspaces?organization_id=1&owner=me"],
125125
])(`getWorkspacesURL(%p) returns %p`, (filter, expected) => {
126126
expect(getWorkspacesURL(filter)).toBe(expected)
127127
})

site/src/api/api.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -117,11 +117,14 @@ export const getWorkspacesURL = (filter?: TypesGen.WorkspaceFilter): string => {
117117
const basePath = "/api/v2/workspaces"
118118
const searchParams = new URLSearchParams()
119119

120-
if (filter?.OrganizationID) {
121-
searchParams.append("organization_id", filter.OrganizationID)
120+
if (filter?.organization_id) {
121+
searchParams.append("organization_id", filter.organization_id)
122122
}
123-
if (filter?.Owner) {
124-
searchParams.append("owner", filter.Owner)
123+
if (filter?.owner) {
124+
searchParams.append("owner", filter.owner)
125+
}
126+
if (filter?.name) {
127+
searchParams.append("name", filter.name)
125128
}
126129

127130
const searchString = searchParams.toString()

site/src/api/typesGenerated.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -443,8 +443,9 @@ export interface WorkspaceBuildsRequest extends Pagination {
443443

444444
// From codersdk/workspaces.go:200:6
445445
export interface WorkspaceFilter {
446-
readonly OrganizationID: string
447-
readonly Owner: string
446+
readonly organization_id?: string
447+
readonly owner?: string
448+
readonly name?: string
448449
}
449450

450451
// From codersdk/workspaceresources.go:21:6
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,169 @@
1+
import Button from "@material-ui/core/Button"
2+
import Fade from "@material-ui/core/Fade"
3+
import InputAdornment from "@material-ui/core/InputAdornment"
4+
import Link from "@material-ui/core/Link"
5+
import Menu from "@material-ui/core/Menu"
6+
import MenuItem from "@material-ui/core/MenuItem"
7+
import { makeStyles } from "@material-ui/core/styles"
8+
import TextField from "@material-ui/core/TextField"
9+
import AddCircleOutline from "@material-ui/icons/AddCircleOutline"
10+
import SearchIcon from "@material-ui/icons/Search"
111
import { useMachine } from "@xstate/react"
2-
import { FC } from "react"
12+
import { FormikErrors, useFormik } from "formik"
13+
import { FC, useState } from "react"
14+
import { Link as RouterLink } from "react-router-dom"
15+
import { CloseDropdown, OpenDropdown } from "../../components/DropdownArrows/DropdownArrows"
16+
import { Margins } from "../../components/Margins/Margins"
17+
import { Stack } from "../../components/Stack/Stack"
18+
import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils"
319
import { workspacesMachine } from "../../xServices/workspaces/workspacesXService"
420
import { WorkspacesPageView } from "./WorkspacesPageView"
521

22+
interface FilterFormValues {
23+
query: string
24+
}
25+
26+
const Language = {
27+
filterName: "Filters",
28+
createWorkspaceButton: "Create workspace",
29+
yourWorkspacesButton: "Your workspaces",
30+
allWorkspacesButton: "All workspaces",
31+
}
32+
33+
export type FilterFormErrors = FormikErrors<FilterFormValues>
34+
635
const WorkspacesPage: FC = () => {
7-
const [workspacesState] = useMachine(workspacesMachine)
36+
const styles = useStyles()
37+
const [workspacesState, send] = useMachine(workspacesMachine)
38+
39+
const form = useFormik<FilterFormValues>({
40+
initialValues: {
41+
query: workspacesState.context.filter || "",
42+
},
43+
onSubmit: (values) => {
44+
send({
45+
type: "SET_FILTER",
46+
query: values.query,
47+
})
48+
},
49+
})
50+
51+
const getFieldHelpers = getFormHelpers<FilterFormValues>(form)
52+
53+
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
54+
55+
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
56+
setAnchorEl(event.currentTarget)
57+
}
58+
59+
const handleClose = () => {
60+
setAnchorEl(null)
61+
}
62+
63+
const setYourWorkspaces = () => {
64+
void form.setFieldValue("query", "owner:me")
65+
void form.submitForm()
66+
handleClose()
67+
}
68+
69+
const setAllWorkspaces = () => {
70+
void form.setFieldValue("query", "")
71+
void form.submitForm()
72+
handleClose()
73+
}
874

975
return (
10-
<>
11-
<WorkspacesPageView
12-
loading={workspacesState.hasTag("loading")}
13-
workspaces={workspacesState.context.workspaces}
14-
error={workspacesState.context.getWorkspacesError}
15-
/>
16-
</>
76+
<Margins>
77+
<Stack direction="row" className={styles.workspacesHeaderContainer}>
78+
<Stack direction="column" className={styles.filterColumn}>
79+
<Stack direction="row" spacing={0} className={styles.filterContainer}>
80+
<Button
81+
aria-controls="filter-menu"
82+
aria-haspopup="true"
83+
onClick={handleClick}
84+
className={styles.buttonRoot}
85+
>
86+
{Language.filterName} {anchorEl ? <CloseDropdown /> : <OpenDropdown />}
87+
</Button>
88+
89+
<form onSubmit={form.handleSubmit} className={styles.filterForm}>
90+
<TextField
91+
{...getFieldHelpers("query")}
92+
className={styles.textFieldRoot}
93+
onChange={onChangeTrimmed(form)}
94+
fullWidth
95+
variant="outlined"
96+
InputProps={{
97+
startAdornment: (
98+
<InputAdornment position="start">
99+
<SearchIcon fontSize="small" />
100+
</InputAdornment>
101+
),
102+
}}
103+
/>
104+
</form>
105+
106+
<Menu
107+
id="filter-menu"
108+
anchorEl={anchorEl}
109+
keepMounted
110+
open={Boolean(anchorEl)}
111+
onClose={handleClose}
112+
TransitionComponent={Fade}
113+
anchorOrigin={{
114+
vertical: "bottom",
115+
horizontal: "left",
116+
}}
117+
transformOrigin={{
118+
vertical: "top",
119+
horizontal: "left",
120+
}}
121+
>
122+
<MenuItem onClick={setYourWorkspaces}>{Language.yourWorkspacesButton}</MenuItem>
123+
<MenuItem onClick={setAllWorkspaces}>{Language.allWorkspacesButton}</MenuItem>
124+
</Menu>
125+
</Stack>
126+
</Stack>
127+
128+
<Link underline="none" component={RouterLink} to="/workspaces/new">
129+
<Button startIcon={<AddCircleOutline />} style={{ height: "44px" }}>
130+
{Language.createWorkspaceButton}
131+
</Button>
132+
</Link>
133+
</Stack>
134+
<WorkspacesPageView loading={workspacesState.hasTag("loading")} workspaces={workspacesState.context.workspaces} />
135+
</Margins>
17136
)
18137
}
19138

139+
const useStyles = makeStyles((theme) => ({
140+
workspacesHeaderContainer: {
141+
marginTop: theme.spacing(3),
142+
marginBottom: theme.spacing(3),
143+
justifyContent: "space-between",
144+
},
145+
filterColumn: {
146+
width: "60%",
147+
cursor: "text",
148+
},
149+
filterContainer: {
150+
border: `1px solid ${theme.palette.divider}`,
151+
borderRadius: "6px",
152+
},
153+
filterForm: {
154+
width: "100%",
155+
},
156+
buttonRoot: {
157+
border: "none",
158+
borderRight: `1px solid ${theme.palette.divider}`,
159+
borderRadius: "6px 0px 0px 6px",
160+
},
161+
textFieldRoot: {
162+
margin: "0px",
163+
"& fieldset": {
164+
border: "none",
165+
},
166+
},
167+
}))
168+
20169
export default WorkspacesPage

0 commit comments

Comments
 (0)