Skip to content

Commit 17ebec2

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

File tree

14 files changed

+377
-121
lines changed

14 files changed

+377
-121
lines changed

coderd/database/databasefake/databasefake.go

Lines changed: 3 additions & 0 deletions
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

Lines changed: 13 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/workspaces.sql

Lines changed: 6 additions & 0 deletions
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

Lines changed: 10 additions & 13 deletions
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

Lines changed: 31 additions & 0 deletions
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

Lines changed: 6 additions & 2 deletions
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

Lines changed: 3 additions & 3 deletions
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

Lines changed: 7 additions & 4 deletions
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

Lines changed: 3 additions & 2 deletions
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
Lines changed: 158 additions & 9 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy