diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 88c17101f833c..2a97dd0d1419e 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -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) } diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 4d98d9ee2c4a8..5aaf9d8f9b389 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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 } diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 000e4e92ce5a9..291f04c96da7a 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -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 diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 43c39140a7e53..744dbd553df45 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -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) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 392007dcdf18a..9ee42fd12ea81 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -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) { diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index cbf94f392da60..dbe3ba1b8574c 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -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() } } diff --git a/site/src/api/api.test.ts b/site/src/api/api.test.ts index 5714c080d384e..083eb177fb6ac 100644 --- a/site/src/api/api.test.ts +++ b/site/src/api/api.test.ts @@ -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) }) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 4e06ccc5306e8..4d4cc5c970fb8 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -116,11 +116,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() diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index d0ce2c3fac371..4a0f3e267027b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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 diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 3d20b318c4e26..00ae3bff7bcf3 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -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 + const WorkspacesPage: FC = () => { - const [workspacesState] = useMachine(workspacesMachine) + const styles = useStyles() + const [workspacesState, send] = useMachine(workspacesMachine) + + const form = useFormik({ + initialValues: { + query: workspacesState.context.filter || "", + }, + onSubmit: (values) => { + send({ + type: "SET_FILTER", + query: values.query, + }) + }, + }) + + const getFieldHelpers = getFormHelpers(form) + + const [anchorEl, setAnchorEl] = useState(null) + + const handleClick = (event: React.MouseEvent) => { + 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 ( - <> - - + + + + + + +
+ + + + ), + }} + /> + + + + {Language.yourWorkspacesButton} + {Language.allWorkspacesButton} + +
+
+ + + + +
+ +
) } +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 diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 2a412116be7bb..2a7c8a22b4a40 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -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 = (props) => { - const styles = useStyles() +export const WorkspacesPageView: FC = ({ loading, workspaces }) => { + useStyles() const theme: Theme = useTheme() + return ( - -
- - - -
- - +
+ + + Name + Template + Version + Last Built + Status + + + + {!workspaces && loading && } + {workspaces && workspaces.length === 0 && ( - Name - Template - Version - Last Built - Status + + + + + } + /> + - - - {props.loading && } - {props.workspaces && props.workspaces.length === 0 && ( - - - - - - } - /> - - - )} - {props.workspaces && - props.workspaces.map((workspace) => { - const status = getDisplayStatus(theme, workspace.latest_build) - return ( - - - - - {workspace.template_name} - - {workspace.outdated ? ( - outdated - ) : ( - up to date - )} - - - - {dayjs().to(dayjs(workspace.latest_build.created_at))} - - - - {status.status} - - - ) - })} - -
-
+ )} + {workspaces && + workspaces.map((workspace) => { + const status = getDisplayStatus(theme, workspace.latest_build) + return ( + + + + + {workspace.template_name} + + {workspace.outdated ? ( + outdated + ) : ( + up to date + )} + + + + {dayjs().to(dayjs(workspace.latest_build.created_at))} + + + + {status.status} + + + ) + })} + +
) } 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), diff --git a/site/src/util/workspace.test.ts b/site/src/util/workspace.test.ts index f14f5a04bdc40..3813f1ab50411 100644 --- a/site/src/util/workspace.test.ts +++ b/site/src/util/workspace.test.ts @@ -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) + }) + }) }) diff --git a/site/src/util/workspace.ts b/site/src/util/workspace.ts index f717e262a4587..44dec857ab3d8 100644 --- a/site/src/util/workspace.ts +++ b/site/src/util/workspace.ts @@ -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 + } +} diff --git a/site/src/xServices/workspaces/workspacesXService.ts b/site/src/xServices/workspaces/workspacesXService.ts index 353e71b234a73..d4367b7ad95a3 100644 --- a/site/src/xServices/workspaces/workspacesXService.ts +++ b/site/src/xServices/workspaces/workspacesXService.ts @@ -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)), }, }, )