feat: Workspaces filtering (#1972)

Co-authored-by: G r e y <grey@coder.com>
Co-authored-by: Kira Pilot <kira@coder.com>
This commit is contained in:
Garrett Delfosse
2022-06-03 17:20:28 +00:00
committed by GitHub
co-authored by G r e y Kira Pilot
parent ac6cb269db
commit 8b03e2b0e1
14 changed files with 377 additions and 121 deletions
@@ -328,6 +328,9 @@ func (q *fakeQuerier) GetWorkspacesWithFilter(_ context.Context, arg database.Ge
if !arg.Deleted && workspace.Deleted {
continue
}
if arg.Name != "" && workspace.Name != arg.Name {
continue
}
workspaces = append(workspaces, workspace)
}
+13 -1
View File
@@ -3509,16 +3509,28 @@ WHERE
owner_id = $3
ELSE true
END
-- Filter by name
AND CASE
WHEN $4 :: text != '' THEN
LOWER(name) = LOWER($4)
ELSE true
END
`
type GetWorkspacesWithFilterParams struct {
Deleted bool `db:"deleted" json:"deleted"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
Name string `db:"name" json:"name"`
}
func (q *sqlQuerier) GetWorkspacesWithFilter(ctx context.Context, arg GetWorkspacesWithFilterParams) ([]Workspace, error) {
rows, err := q.db.QueryContext(ctx, getWorkspacesWithFilter, arg.Deleted, arg.OrganizationID, arg.OwnerID)
rows, err := q.db.QueryContext(ctx, getWorkspacesWithFilter,
arg.Deleted,
arg.OrganizationID,
arg.OwnerID,
arg.Name,
)
if err != nil {
return nil, err
}
+6
View File
@@ -28,6 +28,12 @@ WHERE
owner_id = @owner_id
ELSE true
END
-- Filter by name
AND CASE
WHEN @name :: text != '' THEN
LOWER(name) = LOWER(@name)
ELSE true
END
;
-- name: GetWorkspacesByOrganizationIDs :many
+10 -13
View File
@@ -137,17 +137,14 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
// Empty strings mean no filter
orgFilter := r.URL.Query().Get("organization_id")
ownerFilter := r.URL.Query().Get("owner")
nameFilter := r.URL.Query().Get("name")
filter := database.GetWorkspacesWithFilterParams{Deleted: false}
if orgFilter != "" {
orgID, err := uuid.Parse(orgFilter)
if err != nil {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: fmt.Sprintf("organization_id must be a uuid: %s", err.Error()),
})
return
if err == nil {
filter.OrganizationID = orgID
}
filter.OrganizationID = orgID
}
if ownerFilter == "me" {
filter.OwnerID = apiKey.UserID
@@ -160,15 +157,15 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
Username: ownerFilter,
Email: ownerFilter,
})
if err != nil {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: "owner must be a uuid or username",
})
return
if err == nil {
filter.OwnerID = user.ID
}
userID = user.ID
} else {
filter.OwnerID = userID
}
filter.OwnerID = userID
}
if nameFilter != "" {
filter.Name = nameFilter
}
workspaces, err := api.Database.GetWorkspacesWithFilter(r.Context(), filter)
+31
View File
@@ -268,6 +268,37 @@ func TestWorkspacesByOwner(t *testing.T) {
require.NoError(t, err)
require.Len(t, workspaces, 1)
})
t.Run("ListName", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true})
user := coderdtest.CreateFirstUser(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
w := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
// Create noise workspace that should be filtered out
_ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
// Use name filter
workspaces, err := client.Workspaces(context.Background(), codersdk.WorkspaceFilter{
Name: w.Name,
})
require.NoError(t, err)
require.Len(t, workspaces, 1)
// Create same name workspace that should be included
other := coderdtest.CreateAnotherUser(t, client, user.OrganizationID)
_ = coderdtest.CreateWorkspace(t, other, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { cwr.Name = w.Name })
workspaces, err = client.Workspaces(context.Background(), codersdk.WorkspaceFilter{
Name: w.Name,
})
require.NoError(t, err)
require.Len(t, workspaces, 2)
})
}
func TestWorkspaceByOwnerAndName(t *testing.T) {
+6 -2
View File
@@ -198,9 +198,10 @@ func (c *Client) PutExtendWorkspace(ctx context.Context, id uuid.UUID, req PutEx
}
type WorkspaceFilter struct {
OrganizationID uuid.UUID
OrganizationID uuid.UUID `json:"organization_id,omitempty"`
// Owner can be a user_id (uuid), "me", or a username
Owner string
Owner string `json:"owner,omitempty"`
Name string `json:"name,omitempty"`
}
// asRequestOption returns a function that can be used in (*Client).Request.
@@ -214,6 +215,9 @@ func (f WorkspaceFilter) asRequestOption() requestOption {
if f.Owner != "" {
q.Set("owner", f.Owner)
}
if f.Name != "" {
q.Set("name", f.Name)
}
r.URL.RawQuery = q.Encode()
}
}
+3 -3
View File
@@ -118,10 +118,10 @@ describe("api.ts", () => {
it.each<[TypesGen.WorkspaceFilter | undefined, string]>([
[undefined, "/api/v2/workspaces"],
[{ OrganizationID: "1", Owner: "" }, "/api/v2/workspaces?organization_id=1"],
[{ OrganizationID: "", Owner: "1" }, "/api/v2/workspaces?owner=1"],
[{ organization_id: "1", owner: "" }, "/api/v2/workspaces?organization_id=1"],
[{ organization_id: "", owner: "1" }, "/api/v2/workspaces?owner=1"],
[{ OrganizationID: "1", Owner: "me" }, "/api/v2/workspaces?organization_id=1&owner=me"],
[{ organization_id: "1", owner: "me" }, "/api/v2/workspaces?organization_id=1&owner=me"],
])(`getWorkspacesURL(%p) returns %p`, (filter, expected) => {
expect(getWorkspacesURL(filter)).toBe(expected)
})
+7 -4
View File
@@ -117,11 +117,14 @@ export const getWorkspacesURL = (filter?: TypesGen.WorkspaceFilter): string => {
const basePath = "/api/v2/workspaces"
const searchParams = new URLSearchParams()
if (filter?.OrganizationID) {
searchParams.append("organization_id", filter.OrganizationID)
if (filter?.organization_id) {
searchParams.append("organization_id", filter.organization_id)
}
if (filter?.Owner) {
searchParams.append("owner", filter.Owner)
if (filter?.owner) {
searchParams.append("owner", filter.owner)
}
if (filter?.name) {
searchParams.append("name", filter.name)
}
const searchString = searchParams.toString()
+3 -2
View File
@@ -443,8 +443,9 @@ export interface WorkspaceBuildsRequest extends Pagination {
// From codersdk/workspaces.go:200:6
export interface WorkspaceFilter {
readonly OrganizationID: string
readonly Owner: string
readonly organization_id?: string
readonly owner?: string
readonly name?: string
}
// From codersdk/workspaceresources.go:21:6
@@ -1,20 +1,169 @@
import Button from "@material-ui/core/Button"
import Fade from "@material-ui/core/Fade"
import InputAdornment from "@material-ui/core/InputAdornment"
import Link from "@material-ui/core/Link"
import Menu from "@material-ui/core/Menu"
import MenuItem from "@material-ui/core/MenuItem"
import { makeStyles } from "@material-ui/core/styles"
import TextField from "@material-ui/core/TextField"
import AddCircleOutline from "@material-ui/icons/AddCircleOutline"
import SearchIcon from "@material-ui/icons/Search"
import { useMachine } from "@xstate/react"
import { FC } from "react"
import { FormikErrors, useFormik } from "formik"
import { FC, useState } from "react"
import { Link as RouterLink } from "react-router-dom"
import { CloseDropdown, OpenDropdown } from "../../components/DropdownArrows/DropdownArrows"
import { Margins } from "../../components/Margins/Margins"
import { Stack } from "../../components/Stack/Stack"
import { getFormHelpers, onChangeTrimmed } from "../../util/formUtils"
import { workspacesMachine } from "../../xServices/workspaces/workspacesXService"
import { WorkspacesPageView } from "./WorkspacesPageView"
interface FilterFormValues {
query: string
}
const Language = {
filterName: "Filters",
createWorkspaceButton: "Create workspace",
yourWorkspacesButton: "Your workspaces",
allWorkspacesButton: "All workspaces",
}
export type FilterFormErrors = FormikErrors<FilterFormValues>
const WorkspacesPage: FC = () => {
const [workspacesState] = useMachine(workspacesMachine)
const styles = useStyles()
const [workspacesState, send] = useMachine(workspacesMachine)
const form = useFormik<FilterFormValues>({
initialValues: {
query: workspacesState.context.filter || "",
},
onSubmit: (values) => {
send({
type: "SET_FILTER",
query: values.query,
})
},
})
const getFieldHelpers = getFormHelpers<FilterFormValues>(form)
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null)
}
const setYourWorkspaces = () => {
void form.setFieldValue("query", "owner:me")
void form.submitForm()
handleClose()
}
const setAllWorkspaces = () => {
void form.setFieldValue("query", "")
void form.submitForm()
handleClose()
}
return (
<>
<WorkspacesPageView
loading={workspacesState.hasTag("loading")}
workspaces={workspacesState.context.workspaces}
error={workspacesState.context.getWorkspacesError}
/>
</>
<Margins>
<Stack direction="row" className={styles.workspacesHeaderContainer}>
<Stack direction="column" className={styles.filterColumn}>
<Stack direction="row" spacing={0} className={styles.filterContainer}>
<Button
aria-controls="filter-menu"
aria-haspopup="true"
onClick={handleClick}
className={styles.buttonRoot}
>
{Language.filterName} {anchorEl ? <CloseDropdown /> : <OpenDropdown />}
</Button>
<form onSubmit={form.handleSubmit} className={styles.filterForm}>
<TextField
{...getFieldHelpers("query")}
className={styles.textFieldRoot}
onChange={onChangeTrimmed(form)}
fullWidth
variant="outlined"
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
}}
/>
</form>
<Menu
id="filter-menu"
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={handleClose}
TransitionComponent={Fade}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
>
<MenuItem onClick={setYourWorkspaces}>{Language.yourWorkspacesButton}</MenuItem>
<MenuItem onClick={setAllWorkspaces}>{Language.allWorkspacesButton}</MenuItem>
</Menu>
</Stack>
</Stack>
<Link underline="none" component={RouterLink} to="/workspaces/new">
<Button startIcon={<AddCircleOutline />} style={{ height: "44px" }}>
{Language.createWorkspaceButton}
</Button>
</Link>
</Stack>
<WorkspacesPageView loading={workspacesState.hasTag("loading")} workspaces={workspacesState.context.workspaces} />
</Margins>
)
}
const useStyles = makeStyles((theme) => ({
workspacesHeaderContainer: {
marginTop: theme.spacing(3),
marginBottom: theme.spacing(3),
justifyContent: "space-between",
},
filterColumn: {
width: "60%",
cursor: "text",
},
filterContainer: {
border: `1px solid ${theme.palette.divider}`,
borderRadius: "6px",
},
filterForm: {
width: "100%",
},
buttonRoot: {
border: "none",
borderRight: `1px solid ${theme.palette.divider}`,
borderRadius: "6px 0px 0px 6px",
},
textFieldRoot: {
margin: "0px",
"& fieldset": {
border: "none",
},
},
}))
export default WorkspacesPage
@@ -15,7 +15,6 @@ import { Link as RouterLink } from "react-router-dom"
import * as TypesGen from "../../api/typesGenerated"
import { AvatarData } from "../../components/AvatarData/AvatarData"
import { EmptyState } from "../../components/EmptyState/EmptyState"
import { Margins } from "../../components/Margins/Margins"
import { Stack } from "../../components/Stack/Stack"
import { TableLoader } from "../../components/TableLoader/TableLoader"
import { getDisplayStatus } from "../../util/workspace"
@@ -31,96 +30,79 @@ export const Language = {
export interface WorkspacesPageViewProps {
loading?: boolean
workspaces?: TypesGen.Workspace[]
error?: unknown
}
export const WorkspacesPageView: FC<WorkspacesPageViewProps> = (props) => {
const styles = useStyles()
export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({ loading, workspaces }) => {
useStyles()
const theme: Theme = useTheme()
return (
<Stack spacing={4}>
<Margins>
<div className={styles.actions}>
<Link underline="none" component={RouterLink} to="/workspaces/new">
<Button startIcon={<AddCircleOutline />}>{Language.createButton}</Button>
</Link>
</div>
<Table>
<TableHead>
<Table>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Template</TableCell>
<TableCell>Version</TableCell>
<TableCell>Last Built</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{!workspaces && loading && <TableLoader />}
{workspaces && workspaces.length === 0 && (
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Template</TableCell>
<TableCell>Version</TableCell>
<TableCell>Last Built</TableCell>
<TableCell>Status</TableCell>
<TableCell colSpan={999}>
<EmptyState
message={Language.emptyMessage}
description={Language.emptyDescription}
cta={
<Link underline="none" component={RouterLink} to="/workspaces/new">
<Button startIcon={<AddCircleOutline />}>{Language.createButton}</Button>
</Link>
}
/>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{props.loading && <TableLoader />}
{props.workspaces && props.workspaces.length === 0 && (
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message={Language.emptyMessage}
description={Language.emptyDescription}
cta={
<Link underline="none" component={RouterLink} to="/workspaces/new">
<Button startIcon={<AddCircleOutline />}>{Language.createButton}</Button>
</Link>
}
/>
</TableCell>
</TableRow>
)}
{props.workspaces &&
props.workspaces.map((workspace) => {
const status = getDisplayStatus(theme, workspace.latest_build)
return (
<TableRow key={workspace.id}>
<TableCell>
<AvatarData
title={workspace.name}
subtitle={workspace.owner_name}
link={`/workspaces/${workspace.id}`}
/>
</TableCell>
<TableCell>{workspace.template_name}</TableCell>
<TableCell>
{workspace.outdated ? (
<span style={{ color: theme.palette.error.main }}>outdated</span>
) : (
<span style={{ color: theme.palette.text.secondary }}>up to date</span>
)}
</TableCell>
<TableCell>
<span data-chromatic="ignore" style={{ color: theme.palette.text.secondary }}>
{dayjs().to(dayjs(workspace.latest_build.created_at))}
</span>
</TableCell>
<TableCell>
<span style={{ color: status.color }}>{status.status}</span>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</Margins>
)}
{workspaces &&
workspaces.map((workspace) => {
const status = getDisplayStatus(theme, workspace.latest_build)
return (
<TableRow key={workspace.id}>
<TableCell>
<AvatarData
title={workspace.name}
subtitle={workspace.owner_name}
link={`/workspaces/${workspace.id}`}
/>
</TableCell>
<TableCell>{workspace.template_name}</TableCell>
<TableCell>
{workspace.outdated ? (
<span style={{ color: theme.palette.error.main }}>outdated</span>
) : (
<span style={{ color: theme.palette.text.secondary }}>up to date</span>
)}
</TableCell>
<TableCell>
<span data-chromatic="ignore" style={{ color: theme.palette.text.secondary }}>
{dayjs().to(dayjs(workspace.latest_build.created_at))}
</span>
</TableCell>
<TableCell>
<span style={{ color: status.color }}>{status.status}</span>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</Stack>
)
}
const useStyles = makeStyles((theme) => ({
actions: {
marginTop: theme.spacing(3),
marginBottom: theme.spacing(3),
display: "flex",
height: theme.spacing(6),
"& > *": {
marginLeft: "auto",
},
},
welcome: {
paddingTop: theme.spacing(12),
paddingBottom: theme.spacing(12),
+14 -1
View File
@@ -1,7 +1,7 @@
import dayjs from "dayjs"
import * as TypesGen from "../api/typesGenerated"
import * as Mocks from "../testHelpers/entities"
import { defaultWorkspaceExtension, isWorkspaceOn } from "./workspace"
import { defaultWorkspaceExtension, isWorkspaceOn, workspaceQueryToFilter } from "./workspace"
describe("util > workspace", () => {
describe("isWorkspaceOn", () => {
@@ -63,4 +63,17 @@ describe("util > workspace", () => {
expect(defaultWorkspaceExtension(dayjs(startTime))).toEqual(request)
})
})
describe("workspaceQueryToFilter", () => {
it.each<[string | undefined, TypesGen.WorkspaceFilter]>([
[undefined, {}],
["", {}],
["asdkfvjn", { name: "asdkfvjn" }],
["owner:me", { owner: "me" }],
["owner:me owner:me2", { owner: "me" }],
["me/dev", { owner: "me", name: "dev" }],
[" key:val owner:me ", { owner: "me" }],
])(`query=%p, filter=%p`, (query, filter) => {
expect(workspaceQueryToFilter(query)).toEqual(filter)
})
})
})
+37
View File
@@ -207,3 +207,40 @@ export const defaultWorkspaceExtension = (__startDate?: dayjs.Dayjs): TypesGen.P
deadline: NinetyMinutesFromNow.format(),
}
}
export const workspaceQueryToFilter = (query?: string): TypesGen.WorkspaceFilter => {
const defaultFilter: TypesGen.WorkspaceFilter = {}
const preparedQuery = query?.trim().replace(/ +/g, " ")
if (!preparedQuery) {
return defaultFilter
} else {
const parts = preparedQuery.split(" ")
for (const part of parts) {
const [key, val] = part.split(":")
if (key && val) {
if (key === "owner") {
return {
owner: val,
}
}
// skip invalid key pairs
continue
}
const [username, name] = part.split("/")
if (username && name) {
return {
owner: username,
name: name,
}
}
return {
name: part,
}
}
return defaultFilter
}
}
@@ -1,13 +1,15 @@
import { assign, createMachine } from "xstate"
import * as API from "../../api/api"
import * as TypesGen from "../../api/typesGenerated"
import { workspaceQueryToFilter } from "../../util/workspace"
interface WorkspaceContext {
workspaces?: TypesGen.Workspace[]
filter?: string
getWorkspacesError?: Error | unknown
}
type WorkspaceEvent = { type: "GET_WORKSPACE"; workspaceId: string }
type WorkspaceEvent = { type: "GET_WORKSPACE"; workspaceId: string } | { type: "SET_FILTER"; query: string }
export const workspacesMachine = createMachine(
{
@@ -22,26 +24,38 @@ export const workspacesMachine = createMachine(
},
},
id: "workspaceState",
context: {
filter: "owner:me",
},
initial: "gettingWorkspaces",
states: {
ready: {
on: {
SET_FILTER: "extractingFilter",
},
},
extractingFilter: {
entry: "assignFilter",
always: {
target: "gettingWorkspaces",
},
},
gettingWorkspaces: {
entry: "clearGetWorkspacesError",
invoke: {
src: "getWorkspaces",
id: "getWorkspaces",
onDone: {
target: "done",
target: "ready",
actions: ["assignWorkspaces", "clearGetWorkspacesError"],
},
onError: {
target: "error",
actions: "assignGetWorkspacesError",
target: "ready",
actions: ["assignGetWorkspacesError", "clearWorkspaces"],
},
},
tags: "loading",
},
done: {},
error: {},
},
},
{
@@ -49,13 +63,17 @@ export const workspacesMachine = createMachine(
assignWorkspaces: assign({
workspaces: (_, event) => event.data,
}),
assignFilter: assign({
filter: (_, event) => event.query,
}),
assignGetWorkspacesError: assign({
getWorkspacesError: (_, event) => event.data,
}),
clearGetWorkspacesError: (context) => assign({ ...context, getWorkspacesError: undefined }),
clearWorkspaces: (context) => assign({ ...context, workspaces: undefined }),
},
services: {
getWorkspaces: () => API.getWorkspaces(),
getWorkspaces: (context) => API.getWorkspaces(workspaceQueryToFilter(context.filter)),
},
},
)