diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index be5f9216eff2c..55639ed4cf74a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3498,16 +3498,28 @@ WHERE owner_id = $3 ELSE true END + -- Filter by name + AND CASE + WHEN $4 :: string != '' THEN + 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..e406d3c62238a 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 :: string != '' THEN + name = LOWER(@name) + ELSE true + END ; -- name: GetWorkspacesByOrganizationIDs :many diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 7de4e0a806e15..933a87c54a747 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/codersdk/workspaces.go b/codersdk/workspaces.go index 3ad1ac42fb830..2535644d695e7 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 68c41b77e2ae7..6ee267b6f7aed 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 3c5d5df800fa6..56a4ea5155c1d 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -88,7 +88,7 @@ export interface CreateUserRequest { readonly organization_id: string } -// From codersdk/workspaces.go:34:6 +// From codersdk/workspaces.go:35:6 export interface CreateWorkspaceBuildRequest { readonly template_version_id?: string readonly transition: WorkspaceTransition @@ -222,7 +222,7 @@ export interface ProvisionerJobLog { readonly output: string } -// From codersdk/workspaces.go:182:6 +// From codersdk/workspaces.go:183:6 export interface PutExtendWorkspaceRequest { readonly deadline: string } @@ -299,12 +299,12 @@ export interface UpdateUserProfileRequest { readonly username: string } -// From codersdk/workspaces.go:141:6 +// From codersdk/workspaces.go:142:6 export interface UpdateWorkspaceAutostartRequest { readonly schedule: string } -// From codersdk/workspaces.go:161:6 +// From codersdk/workspaces.go:162:6 export interface UpdateWorkspaceTTLRequest { // This is likely an enum in an external package ("time.Duration") readonly ttl?: number @@ -360,7 +360,7 @@ export interface UsersRequest extends Pagination { readonly status?: string } -// From codersdk/workspaces.go:18:6 +// From codersdk/workspaces.go:19:6 export interface Workspace { readonly id: string readonly created_at: string @@ -438,15 +438,16 @@ export interface WorkspaceBuild { readonly deadline: string } -// From codersdk/workspaces.go:64:6 +// From codersdk/workspaces.go:65:6 export interface WorkspaceBuildsRequest extends Pagination { readonly WorkspaceID: string } -// From codersdk/workspaces.go:200:6 +// From codersdk/workspaces.go:201: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..bc49893e2e94b 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 + + + + {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 69657a75015bd..846f3b46b2a5d 100644 --- a/site/src/util/workspace.test.ts +++ b/site/src/util/workspace.test.ts @@ -1,6 +1,6 @@ import * as TypesGen from "../api/typesGenerated" import * as Mocks from "../testHelpers/entities" -import { isWorkspaceOn } from "./workspace" +import { isWorkspaceOn, workspaceQueryToFilter } from "./workspace" describe("util > workspace", () => { describe("isWorkspaceOn", () => { @@ -40,4 +40,16 @@ describe("util > workspace", () => { expect(isWorkspaceOn(workspace)).toBe(isOn) }) }) + 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" }], + ])(`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 5e4f678b48680..08b7f4fc58b43 100644 --- a/site/src/util/workspace.ts +++ b/site/src/util/workspace.ts @@ -1,7 +1,7 @@ import { Theme } from "@material-ui/core/styles" import dayjs from "dayjs" import { WorkspaceBuildTransition } from "../api/types" -import { Workspace, WorkspaceAgent, WorkspaceBuild } from "../api/typesGenerated" +import * as TypeGen from "../api/typesGenerated" export type WorkspaceStatus = | "queued" @@ -29,7 +29,7 @@ const succeededToStatus: Record = { } // Converts a workspaces status to a human-readable form. -export const getWorkspaceStatus = (workspaceBuild?: WorkspaceBuild): WorkspaceStatus => { +export const getWorkspaceStatus = (workspaceBuild?: TypeGen.WorkspaceBuild): WorkspaceStatus => { const transition = workspaceBuild?.transition as WorkspaceBuildTransition const jobStatus = workspaceBuild?.job.status switch (jobStatus) { @@ -66,7 +66,7 @@ export const DisplayStatusLanguage = { export const getDisplayStatus = ( theme: Theme, - build: WorkspaceBuild, + build: TypeGen.WorkspaceBuild, ): { color: string status: string @@ -132,7 +132,7 @@ export const getDisplayStatus = ( throw new Error("unknown status " + status) } -export const getWorkspaceBuildDurationInSeconds = (build: WorkspaceBuild): number | undefined => { +export const getWorkspaceBuildDurationInSeconds = (build: TypeGen.WorkspaceBuild): number | undefined => { const isCompleted = build.job.started_at && build.job.completed_at if (!isCompleted) { @@ -144,7 +144,10 @@ export const getWorkspaceBuildDurationInSeconds = (build: WorkspaceBuild): numbe return completedAt.diff(startedAt, "seconds") } -export const displayWorkspaceBuildDuration = (build: WorkspaceBuild, inProgressLabel = "In progress"): string => { +export const displayWorkspaceBuildDuration = ( + build: TypeGen.WorkspaceBuild, + inProgressLabel = "In progress", +): string => { const duration = getWorkspaceBuildDurationInSeconds(build) return duration ? `${duration} seconds` : inProgressLabel } @@ -157,7 +160,7 @@ export const DisplayAgentStatusLanguage = { export const getDisplayAgentStatus = ( theme: Theme, - agent: WorkspaceAgent, + agent: TypeGen.WorkspaceAgent, ): { color: string status: string @@ -186,8 +189,41 @@ export const getDisplayAgentStatus = ( } } -export const isWorkspaceOn = (workspace: Workspace): boolean => { +export const isWorkspaceOn = (workspace: TypeGen.Workspace): boolean => { const transition = workspace.latest_build.transition const status = workspace.latest_build.job.status return transition === "start" && status === "succeeded" } + +export const workspaceQueryToFilter = (query?: string): TypeGen.WorkspaceFilter => { + const defaultFilter: TypeGen.WorkspaceFilter = {} + const preparedQuery = query?.replace(/ +/g, " ") + + if (!preparedQuery) { + return defaultFilter + } else { + const parts = preparedQuery.split(" ") + + for (const part of parts) { + const [key, val] = part.split(":") + if (key === "owner") { + return { + owner: val, + } + } + + 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)), }, }, )