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)), }, }, ) 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