mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
co-authored by
G r e y
Kira Pilot
parent
ac6cb269db
commit
8b03e2b0e1
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user