feat: paginate workspaces page (#4647)

* Start - still needs api call changes

* Some xservice changes

* Finish adding count to xservice

* Mock out api call on frontend

* Handle errors

* Doctor getWorkspaces

* Add types, start writing count function

* Hook up route

* Use empty page struct

* Write interface and database fake

* SQL query

* Fix params type

* Missed a spot

* Space after alert banner

* Fix model queries

* Unpack query correctly

* Fix filter-page interaction

* Make mobile friendly

* Format

* Test backend

* Fix key

* Delete unnecessary conditional

* Add test helpers

* Use limit constant

* Show widget with no count

* Add test

* Format

* make gen from garretts workspace idk why

* fix authorize test'

* Hide widget with 0 records

* Fix tests

* Format

* Fix types generated

* Fix story

* Add alert banner story

* Format

* Fix import

* Format

* Try removing story

* Revert "Fix story"

This reverts commit c06765b7fb.

* Add counts to page view story

* Revert "Try removing story"

This reverts commit 476019b041.

Co-authored-by: Garrett <garrett@coder.com>
This commit is contained in:
Presley Pizzo 2022-10-20 13:23:14 -04:00 committed by GitHub
parent 423ac04156
commit 7c238f13e5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 1074 additions and 187 deletions

View File

@ -503,6 +503,7 @@ func New(options *Options) *API {
apiKeyMiddleware,
)
r.Get("/", api.workspaces)
r.Get("/count", api.workspaceCount)
r.Route("/{workspace}", func(r chi.Router) {
r.Use(
httpmw.ExtractWorkspaceParam(options.Database),

View File

@ -243,7 +243,8 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) {
"POST:/api/v2/organizations/{organization}/templateversions": {StatusCode: http.StatusBadRequest, NoAuthorize: true},
// Endpoints that use the SQLQuery filter.
"GET:/api/v2/workspaces/": {StatusCode: http.StatusOK, NoAuthorize: true},
"GET:/api/v2/workspaces/": {StatusCode: http.StatusOK, NoAuthorize: true},
"GET:/api/v2/workspaces/count": {StatusCode: http.StatusOK, NoAuthorize: true},
}
// Routes like proxy routes support all HTTP methods. A helper func to expand

View File

@ -788,6 +788,156 @@ func (q *fakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database.
return workspaces, nil
}
func (q *fakeQuerier) GetWorkspaceCount(ctx context.Context, arg database.GetWorkspaceCountParams) (int64, error) {
count, err := q.GetAuthorizedWorkspaceCount(ctx, arg, nil)
return count, err
}
//nolint:gocyclo
func (q *fakeQuerier) GetAuthorizedWorkspaceCount(ctx context.Context, arg database.GetWorkspaceCountParams, authorizedFilter rbac.AuthorizeFilter) (int64, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
workspaces := make([]database.Workspace, 0)
for _, workspace := range q.workspaces {
if arg.OwnerID != uuid.Nil && workspace.OwnerID != arg.OwnerID {
continue
}
if arg.OwnerUsername != "" {
owner, err := q.GetUserByID(ctx, workspace.OwnerID)
if err == nil && !strings.EqualFold(arg.OwnerUsername, owner.Username) {
continue
}
}
if arg.TemplateName != "" {
template, err := q.GetTemplateByID(ctx, workspace.TemplateID)
if err == nil && !strings.EqualFold(arg.TemplateName, template.Name) {
continue
}
}
if !arg.Deleted && workspace.Deleted {
continue
}
if arg.Name != "" && !strings.Contains(strings.ToLower(workspace.Name), strings.ToLower(arg.Name)) {
continue
}
if arg.Status != "" {
build, err := q.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
if err != nil {
return 0, xerrors.Errorf("get latest build: %w", err)
}
job, err := q.GetProvisionerJobByID(ctx, build.JobID)
if err != nil {
return 0, xerrors.Errorf("get provisioner job: %w", err)
}
switch arg.Status {
case "pending":
if !job.StartedAt.Valid {
continue
}
case "starting":
if !job.StartedAt.Valid &&
!job.CanceledAt.Valid &&
job.CompletedAt.Valid &&
time.Since(job.UpdatedAt) > 30*time.Second ||
build.Transition != database.WorkspaceTransitionStart {
continue
}
case "running":
if !job.CompletedAt.Valid &&
job.CanceledAt.Valid &&
job.Error.Valid ||
build.Transition != database.WorkspaceTransitionStart {
continue
}
case "stopping":
if !job.StartedAt.Valid &&
!job.CanceledAt.Valid &&
job.CompletedAt.Valid &&
time.Since(job.UpdatedAt) > 30*time.Second ||
build.Transition != database.WorkspaceTransitionStop {
continue
}
case "stopped":
if !job.CompletedAt.Valid &&
job.CanceledAt.Valid &&
job.Error.Valid ||
build.Transition != database.WorkspaceTransitionStop {
continue
}
case "failed":
if (!job.CanceledAt.Valid && !job.Error.Valid) ||
(!job.CompletedAt.Valid && !job.Error.Valid) {
continue
}
case "canceling":
if !job.CanceledAt.Valid && job.CompletedAt.Valid {
continue
}
case "canceled":
if !job.CanceledAt.Valid && !job.CompletedAt.Valid {
continue
}
case "deleted":
if !job.StartedAt.Valid &&
job.CanceledAt.Valid &&
!job.CompletedAt.Valid &&
time.Since(job.UpdatedAt) > 30*time.Second ||
build.Transition != database.WorkspaceTransitionDelete {
continue
}
case "deleting":
if !job.CompletedAt.Valid &&
job.CanceledAt.Valid &&
job.Error.Valid &&
build.Transition != database.WorkspaceTransitionDelete {
continue
}
default:
return 0, xerrors.Errorf("unknown workspace status in filter: %q", arg.Status)
}
}
if len(arg.TemplateIds) > 0 {
match := false
for _, id := range arg.TemplateIds {
if workspace.TemplateID == id {
match = true
break
}
}
if !match {
continue
}
}
// If the filter exists, ensure the object is authorized.
if authorizedFilter != nil && !authorizedFilter.Eval(workspace.RBACObject()) {
continue
}
workspaces = append(workspaces, workspace)
}
return int64(len(workspaces)), nil
}
func (q *fakeQuerier) GetWorkspaceByID(_ context.Context, id uuid.UUID) (database.Workspace, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()

View File

@ -112,6 +112,7 @@ func (q *sqlQuerier) GetTemplateGroupRoles(ctx context.Context, id uuid.UUID) ([
type workspaceQuerier interface {
GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspacesParams, authorizedFilter rbac.AuthorizeFilter) ([]Workspace, error)
GetAuthorizedWorkspaceCount(ctx context.Context, arg GetWorkspaceCountParams, authorizedFilter rbac.AuthorizeFilter) (int64, error)
}
// GetAuthorizedWorkspaces returns all workspaces that the user is authorized to access.
@ -166,3 +167,23 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
}
return items, nil
}
func (q *sqlQuerier) GetAuthorizedWorkspaceCount(ctx context.Context, arg GetWorkspaceCountParams, authorizedFilter rbac.AuthorizeFilter) (int64, error) {
// In order to properly use ORDER BY, OFFSET, and LIMIT, we need to inject the
// authorizedFilter between the end of the where clause and those statements.
filter := strings.Replace(getWorkspaceCount, "-- @authorize_filter", fmt.Sprintf(" AND %s", authorizedFilter.SQLString(rbac.NoACLConfig())), 1)
// The name comment is for metric tracking
query := fmt.Sprintf("-- name: GetAuthorizedWorkspaceCount :one\n%s", filter)
row := q.db.QueryRowContext(ctx, query,
arg.Deleted,
arg.Status,
arg.OwnerID,
arg.OwnerUsername,
arg.TemplateName,
pq.Array(arg.TemplateIds),
arg.Name,
)
var count int64
err := row.Scan(&count)
return count, err
}

View File

@ -110,6 +110,8 @@ type sqlcQuerier interface {
GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceBuild, error)
GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Workspace, error)
GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWorkspaceByOwnerIDAndNameParams) (Workspace, error)
// this duplicates the filtering in GetWorkspaces
GetWorkspaceCount(ctx context.Context, arg GetWorkspaceCountParams) (int64, error)
GetWorkspaceCountByUserID(ctx context.Context, ownerID uuid.UUID) (int64, error)
GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, ids []uuid.UUID) ([]GetWorkspaceOwnerCountsByTemplateIDsRow, error)
GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error)

View File

@ -5756,6 +5756,160 @@ func (q *sqlQuerier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWo
return i, err
}
const getWorkspaceCount = `-- name: GetWorkspaceCount :one
SELECT
COUNT(*) as count
FROM
workspaces
LEFT JOIN LATERAL (
SELECT
workspace_builds.transition,
provisioner_jobs.started_at,
provisioner_jobs.updated_at,
provisioner_jobs.canceled_at,
provisioner_jobs.completed_at,
provisioner_jobs.error
FROM
workspace_builds
LEFT JOIN
provisioner_jobs
ON
provisioner_jobs.id = workspace_builds.job_id
WHERE
workspace_builds.workspace_id = workspaces.id
ORDER BY
build_number DESC
LIMIT
1
) latest_build ON TRUE
WHERE
-- Optionally include deleted workspaces
workspaces.deleted = $1
AND CASE
WHEN $2 :: text != '' THEN
CASE
WHEN $2 = 'pending' THEN
latest_build.started_at IS NULL
WHEN $2 = 'starting' THEN
latest_build.started_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.completed_at IS NULL AND
latest_build.updated_at - INTERVAL '30 seconds' < NOW() AND
latest_build.transition = 'start'::workspace_transition
WHEN $2 = 'running' THEN
latest_build.completed_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.error IS NULL AND
latest_build.transition = 'start'::workspace_transition
WHEN $2 = 'stopping' THEN
latest_build.started_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.completed_at IS NULL AND
latest_build.updated_at - INTERVAL '30 seconds' < NOW() AND
latest_build.transition = 'stop'::workspace_transition
WHEN $2 = 'stopped' THEN
latest_build.completed_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.error IS NULL AND
latest_build.transition = 'stop'::workspace_transition
WHEN $2 = 'failed' THEN
(latest_build.canceled_at IS NOT NULL AND
latest_build.error IS NOT NULL) OR
(latest_build.completed_at IS NOT NULL AND
latest_build.error IS NOT NULL)
WHEN $2 = 'canceling' THEN
latest_build.canceled_at IS NOT NULL AND
latest_build.completed_at IS NULL
WHEN $2 = 'canceled' THEN
latest_build.canceled_at IS NOT NULL AND
latest_build.completed_at IS NOT NULL
WHEN $2 = 'deleted' THEN
latest_build.started_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.completed_at IS NOT NULL AND
latest_build.updated_at - INTERVAL '30 seconds' < NOW() AND
latest_build.transition = 'delete'::workspace_transition
WHEN $2 = 'deleting' THEN
latest_build.completed_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.error IS NULL AND
latest_build.transition = 'delete'::workspace_transition
ELSE
true
END
ELSE true
END
-- Filter by owner_id
AND CASE
WHEN $3 :: uuid != '00000000-00000000-00000000-00000000' THEN
owner_id = $3
ELSE true
END
-- Filter by owner_name
AND CASE
WHEN $4 :: text != '' THEN
owner_id = (SELECT id FROM users WHERE lower(username) = lower($4) AND deleted = false)
ELSE true
END
-- Filter by template_name
-- There can be more than 1 template with the same name across organizations.
-- Use the organization filter to restrict to 1 org if needed.
AND CASE
WHEN $5 :: text != '' THEN
template_id = ANY(SELECT id FROM templates WHERE lower(name) = lower($5) AND deleted = false)
ELSE true
END
-- Filter by template_ids
AND CASE
WHEN array_length($6 :: uuid[], 1) > 0 THEN
template_id = ANY($6)
ELSE true
END
-- Filter by name, matching on substring
AND CASE
WHEN $7 :: text != '' THEN
name ILIKE '%' || $7 || '%'
ELSE true
END
-- Authorize Filter clause will be injected below in GetAuthorizedWorkspaceCount
-- @authorize_filter
`
type GetWorkspaceCountParams struct {
Deleted bool `db:"deleted" json:"deleted"`
Status string `db:"status" json:"status"`
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
OwnerUsername string `db:"owner_username" json:"owner_username"`
TemplateName string `db:"template_name" json:"template_name"`
TemplateIds []uuid.UUID `db:"template_ids" json:"template_ids"`
Name string `db:"name" json:"name"`
}
// this duplicates the filtering in GetWorkspaces
func (q *sqlQuerier) GetWorkspaceCount(ctx context.Context, arg GetWorkspaceCountParams) (int64, error) {
row := q.db.QueryRowContext(ctx, getWorkspaceCount,
arg.Deleted,
arg.Status,
arg.OwnerID,
arg.OwnerUsername,
arg.TemplateName,
pq.Array(arg.TemplateIds),
arg.Name,
)
var count int64
err := row.Scan(&count)
return count, err
}
const getWorkspaceCountByUserID = `-- name: GetWorkspaceCountByUserID :one
SELECT
COUNT(id)

View File

@ -145,6 +145,135 @@ OFFSET
@offset_
;
-- this duplicates the filtering in GetWorkspaces
-- name: GetWorkspaceCount :one
SELECT
COUNT(*) as count
FROM
workspaces
LEFT JOIN LATERAL (
SELECT
workspace_builds.transition,
provisioner_jobs.started_at,
provisioner_jobs.updated_at,
provisioner_jobs.canceled_at,
provisioner_jobs.completed_at,
provisioner_jobs.error
FROM
workspace_builds
LEFT JOIN
provisioner_jobs
ON
provisioner_jobs.id = workspace_builds.job_id
WHERE
workspace_builds.workspace_id = workspaces.id
ORDER BY
build_number DESC
LIMIT
1
) latest_build ON TRUE
WHERE
-- Optionally include deleted workspaces
workspaces.deleted = @deleted
AND CASE
WHEN @status :: text != '' THEN
CASE
WHEN @status = 'pending' THEN
latest_build.started_at IS NULL
WHEN @status = 'starting' THEN
latest_build.started_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.completed_at IS NULL AND
latest_build.updated_at - INTERVAL '30 seconds' < NOW() AND
latest_build.transition = 'start'::workspace_transition
WHEN @status = 'running' THEN
latest_build.completed_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.error IS NULL AND
latest_build.transition = 'start'::workspace_transition
WHEN @status = 'stopping' THEN
latest_build.started_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.completed_at IS NULL AND
latest_build.updated_at - INTERVAL '30 seconds' < NOW() AND
latest_build.transition = 'stop'::workspace_transition
WHEN @status = 'stopped' THEN
latest_build.completed_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.error IS NULL AND
latest_build.transition = 'stop'::workspace_transition
WHEN @status = 'failed' THEN
(latest_build.canceled_at IS NOT NULL AND
latest_build.error IS NOT NULL) OR
(latest_build.completed_at IS NOT NULL AND
latest_build.error IS NOT NULL)
WHEN @status = 'canceling' THEN
latest_build.canceled_at IS NOT NULL AND
latest_build.completed_at IS NULL
WHEN @status = 'canceled' THEN
latest_build.canceled_at IS NOT NULL AND
latest_build.completed_at IS NOT NULL
WHEN @status = 'deleted' THEN
latest_build.started_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.completed_at IS NOT NULL AND
latest_build.updated_at - INTERVAL '30 seconds' < NOW() AND
latest_build.transition = 'delete'::workspace_transition
WHEN @status = 'deleting' THEN
latest_build.completed_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.error IS NULL AND
latest_build.transition = 'delete'::workspace_transition
ELSE
true
END
ELSE true
END
-- Filter by owner_id
AND CASE
WHEN @owner_id :: uuid != '00000000-00000000-00000000-00000000' THEN
owner_id = @owner_id
ELSE true
END
-- Filter by owner_name
AND CASE
WHEN @owner_username :: text != '' THEN
owner_id = (SELECT id FROM users WHERE lower(username) = lower(@owner_username) AND deleted = false)
ELSE true
END
-- Filter by template_name
-- There can be more than 1 template with the same name across organizations.
-- Use the organization filter to restrict to 1 org if needed.
AND CASE
WHEN @template_name :: text != '' THEN
template_id = ANY(SELECT id FROM templates WHERE lower(name) = lower(@template_name) AND deleted = false)
ELSE true
END
-- Filter by template_ids
AND CASE
WHEN array_length(@template_ids :: uuid[], 1) > 0 THEN
template_id = ANY(@template_ids)
ELSE true
END
-- Filter by name, matching on substring
AND CASE
WHEN @name :: text != '' THEN
name ILIKE '%' || @name || '%'
ELSE true
END
-- Authorize Filter clause will be injected below in GetAuthorizedWorkspaceCount
-- @authorize_filter
;
-- name: GetWorkspaceByOwnerIDAndName :one
SELECT
*

View File

@ -157,6 +157,58 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, wss)
}
func (api *API) workspaceCount(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
queryStr := r.URL.Query().Get("q")
filter, errs := workspaceSearchQuery(queryStr, codersdk.Pagination{})
if len(errs) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid audit search query.",
Validations: errs,
})
return
}
if filter.OwnerUsername == "me" {
filter.OwnerID = apiKey.UserID
filter.OwnerUsername = ""
}
sqlFilter, err := api.HTTPAuth.AuthorizeSQLFilter(r, rbac.ActionRead, rbac.ResourceWorkspace.Type)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error preparing sql filter.",
Detail: err.Error(),
})
return
}
countFilter := database.GetWorkspaceCountParams{
Deleted: filter.Deleted,
OwnerUsername: filter.OwnerUsername,
OwnerID: filter.OwnerID,
Name: filter.Name,
Status: filter.Status,
TemplateIds: filter.TemplateIds,
TemplateName: filter.TemplateName,
}
count, err := api.Database.GetAuthorizedWorkspaceCount(ctx, countFilter, sqlFilter)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace count.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceCountResponse{
Count: count,
})
}
func (api *API) workspaceByOwnerAndName(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
owner := httpmw.UserParam(r)

View File

@ -822,6 +822,33 @@ func TestOffsetLimit(t *testing.T) {
require.Len(t, ws, 0)
}
func TestWorkspaceCount(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: 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)
template2 := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
_ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
_ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template2.ID)
_ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template2.ID)
response, err := client.WorkspaceCount(ctx, codersdk.WorkspaceCountRequest{})
require.NoError(t, err, "fetch workspace count")
// counts all
require.Equal(t, int(response.Count), 3)
response2, err2 := client.WorkspaceCount(ctx, codersdk.WorkspaceCountRequest{
SearchQuery: fmt.Sprintf("template:%s", template.Name),
})
require.NoError(t, err2, "fetch workspace count")
// counts only those that pass filter
require.Equal(t, int(response2.Count), 1)
}
func TestPostWorkspaceBuild(t *testing.T) {
t.Parallel()
t.Run("NoTemplateVersion", func(t *testing.T) {

View File

@ -31,6 +31,18 @@ type Workspace struct {
LastUsedAt time.Time `json:"last_used_at"`
}
type WorkspacesRequest struct {
SearchQuery string `json:"q,omitempty"`
Pagination
}
type WorkspaceCountRequest struct {
SearchQuery string `json:"q,omitempty"`
}
type WorkspaceCountResponse struct {
Count int64 `json:"count"`
}
// CreateWorkspaceBuildRequest provides options to update the latest workspace build.
type CreateWorkspaceBuildRequest struct {
TemplateVersionID uuid.UUID `json:"template_version_id,omitempty"`
@ -312,6 +324,34 @@ func (c *Client) Workspaces(ctx context.Context, filter WorkspaceFilter) ([]Work
return workspaces, json.NewDecoder(res.Body).Decode(&workspaces)
}
func (c *Client) WorkspaceCount(ctx context.Context, req WorkspaceCountRequest) (WorkspaceCountResponse, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/workspaces/count", nil, func(r *http.Request) {
q := r.URL.Query()
var params []string
if req.SearchQuery != "" {
params = append(params, req.SearchQuery)
}
q.Set("q", strings.Join(params, " "))
r.URL.RawQuery = q.Encode()
})
if err != nil {
return WorkspaceCountResponse{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return WorkspaceCountResponse{}, readBodyAsError(res)
}
var countRes WorkspaceCountResponse
err = json.NewDecoder(res.Body).Decode(&countRes)
if err != nil {
return WorkspaceCountResponse{}, err
}
return countRes, nil
}
// WorkspaceByOwnerAndName returns a workspace by the owner's UUID and the workspace's name.
func (c *Client) WorkspaceByOwnerAndName(ctx context.Context, owner string, name string, params WorkspaceOptions) (Workspace, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/workspace/%s", owner, name), nil, func(r *http.Request) {

View File

@ -280,10 +280,35 @@ export const getURLWithSearchParams = (
}
export const getWorkspaces = async (
filter?: TypesGen.WorkspaceFilter,
options: TypesGen.WorkspacesRequest,
): Promise<TypesGen.Workspace[]> => {
const url = getURLWithSearchParams("/api/v2/workspaces", filter)
const response = await axios.get<TypesGen.Workspace[]>(url)
const searchParams = new URLSearchParams()
if (options.limit) {
searchParams.set("limit", options.limit.toString())
}
if (options.offset) {
searchParams.set("offset", options.offset.toString())
}
if (options.q) {
searchParams.set("q", options.q)
}
const response = await axios.get<TypesGen.Workspace[]>(
`/api/v2/workspaces?${searchParams.toString()}`,
)
return response.data
}
export const getWorkspacesCount = async (
options: TypesGen.WorkspaceCountRequest,
): Promise<TypesGen.WorkspaceCountResponse> => {
const searchParams = new URLSearchParams()
if (options.q) {
searchParams.set("q", options.q)
}
const response = await axios.get(
`/api/v2/workspaces/count?${searchParams.toString()}`,
)
return response.data
}

View File

@ -845,6 +845,16 @@ export interface WorkspaceBuildsRequest extends Pagination {
readonly Since: string
}
// From codersdk/workspaces.go
export interface WorkspaceCountRequest {
readonly q?: string
}
// From codersdk/workspaces.go
export interface WorkspaceCountResponse {
readonly count: number
}
// From codersdk/workspaces.go
export interface WorkspaceFilter {
readonly q?: string
@ -882,6 +892,11 @@ export interface WorkspaceResourceMetadata {
readonly sensitive: boolean
}
// From codersdk/workspaces.go
export interface WorkspacesRequest extends Pagination {
readonly q?: string
}
// From codersdk/apikey.go
export type APIKeyScope = "all" | "application_connect"

View File

@ -1,7 +1,8 @@
import { Story } from "@storybook/react"
import { AlertBanner, AlertBannerProps } from "./AlertBanner"
import { AlertBanner } from "./AlertBanner"
import Button from "@material-ui/core/Button"
import { makeMockApiError } from "testHelpers/entities"
import { AlertBannerProps } from "./alertTypes"
export default {
title: "components/AlertBanner",
@ -99,3 +100,9 @@ ErrorWithActionRetryAndDismiss.args = {
dismissible: true,
severity: "error",
}
export const ErrorAsWarning = Template.bind({})
ErrorAsWarning.args = {
error: mockError,
severity: "warning",
}

View File

@ -11,12 +11,12 @@ import { severityConstants } from "./severityConstants"
import { AlertBannerCtas } from "./AlertBannerCtas"
/**
* severity: the level of alert severity (see ./severityTypes.ts)
* text: default text to be displayed to the user; useful for warnings or as a fallback error message
* error: should be passed in if the severity is 'Error'; warnings can use 'text' instead
* actions: an array of CTAs passed in by the consumer
* dismissible: determines whether or not the banner should have a `Dismiss` CTA
* retry: a handler to retry the action that spawned the error
* @param severity: the level of alert severity (see ./severityTypes.ts)
* @param text: default text to be displayed to the user; useful for warnings or as a fallback error message
* @param error: should be passed in if the severity is 'Error'; warnings can use 'text' instead
* @param actions: an array of CTAs passed in by the consumer
* @param dismissible: determines whether or not the banner should have a `Dismiss` CTA
* @param retry: a handler to retry the action that spawned the error
*/
export const AlertBanner: FC<AlertBannerProps> = ({
severity,

View File

@ -1,7 +1,10 @@
import Button from "@material-ui/core/Button"
import { makeStyles } from "@material-ui/core/styles"
import { makeStyles, useTheme } from "@material-ui/core/styles"
import useMediaQuery from "@material-ui/core/useMediaQuery"
import KeyboardArrowLeft from "@material-ui/icons/KeyboardArrowLeft"
import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight"
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne"
import { Maybe } from "components/Conditionals/Maybe"
import { CSSProperties } from "react"
export type PaginationWidgetProps = {
@ -24,7 +27,7 @@ export type PaginationWidgetProps = {
const range = (start: number, stop: number, step = 1) =>
Array.from({ length: (stop - start) / step + 1 }, (_, i) => start + i * step)
const DEFAULT_RECORDS_PER_PAGE = 25
export const DEFAULT_RECORDS_PER_PAGE = 25
// Number of pages to the left or right of the current page selection.
const PAGE_NEIGHBORS = 1
// Number of pages displayed for cases where there are multiple ellipsis showing. This can be
@ -74,6 +77,38 @@ export const buildPagedList = (
return range(1, numPages)
}
interface PageButtonProps {
activePage: number
page: number
numPages: number
onPageClick?: (page: number) => void
}
const PageButton = ({
activePage,
page,
numPages,
onPageClick,
}: PageButtonProps): JSX.Element => {
const styles = useStyles()
return (
<Button
className={
activePage === page
? `${styles.pageButton} ${styles.activePageButton}`
: styles.pageButton
}
aria-label={`${page === activePage ? "Current Page" : ""} ${
page === numPages ? "Last Page" : ""
} Page${page}`}
name="Page button"
onClick={() => onPageClick && onPageClick(page)}
>
<div>{page}</div>
</Button>
)
}
export const PaginationWidget = ({
prevLabel,
nextLabel,
@ -88,11 +123,12 @@ export const PaginationWidget = ({
const numPages = numRecords ? Math.ceil(numRecords / numRecordsPerPage) : 0
const firstPageActive = activePage === 1 && numPages !== 0
const lastPageActive = activePage === numPages && numPages !== 0
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down("sm"))
const styles = useStyles()
// No need to display any pagination if we know the number of pages is 1
if (numPages === 1) {
if (numPages === 1 || numRecords === 0) {
return null
}
@ -107,30 +143,38 @@ export const PaginationWidget = ({
<KeyboardArrowLeft />
<div>{prevLabel}</div>
</Button>
{numPages > 0 &&
buildPagedList(numPages, activePage).map((page) =>
typeof page !== "number" ? (
<Button className={styles.pageButton} key={`Page${page}`} disabled>
<div>...</div>
</Button>
) : (
<Button
className={
activePage === page
? `${styles.pageButton} ${styles.activePageButton}`
: styles.pageButton
}
aria-label={`${page === activePage ? "Current Page" : ""} ${
page === numPages ? "Last Page" : ""
} Page${page}`}
name="Page button"
key={`Page${page}`}
onClick={() => onPageClick && onPageClick(page)}
>
<div>{page}</div>
</Button>
),
)}
<Maybe condition={numPages > 0}>
<ChooseOne>
<Cond condition={isMobile}>
<PageButton
activePage={activePage}
page={activePage}
numPages={numPages}
/>
</Cond>
<Cond>
{buildPagedList(numPages, activePage).map((page) =>
typeof page !== "number" ? (
<Button
className={styles.pageButton}
key={`Page${page}`}
disabled
>
<div>...</div>
</Button>
) : (
<PageButton
key={`Page${page}`}
activePage={activePage}
page={page}
numPages={numPages}
onPageClick={onPageClick}
/>
),
)}
</Cond>
</ChooseOne>
</Maybe>
<Button
aria-label="Next page"
disabled={lastPageActive}

View File

@ -220,7 +220,7 @@ describe("WorkspacePage", () => {
await waitFor(() =>
expect(api.startWorkspace).toBeCalledWith(
"test-workspace",
"test-outdated-workspace",
"test-template-version",
),
)

View File

@ -1,4 +1,4 @@
import { screen } from "@testing-library/react"
import { screen, waitFor } from "@testing-library/react"
import { rest } from "msw"
import * as CreateDayString from "util/createDayString"
import { Language as WorkspacesTableBodyLanguage } from "../../components/WorkspacesTable/WorkspacesTableBody"
@ -34,9 +34,24 @@ describe("WorkspacesPage", () => {
it("renders a filled workspaces page", async () => {
// When
render(<WorkspacesPage />)
const { container } = render(<WorkspacesPage />)
// Then
const nextPage = await screen.findByRole("button", { name: "Next page" })
expect(nextPage).toBeEnabled()
await waitFor(
async () => {
const prevPage = await screen.findByRole("button", {
name: "Previous page",
})
expect(prevPage).toBeDisabled()
const pageButtons = await container.querySelectorAll(
`button[name="Page button"]`,
)
expect(pageButtons.length).toBe(2)
},
{ timeout: 2000 },
)
await screen.findByText(MockWorkspace.name)
})
})

View File

@ -1,22 +1,43 @@
import { useMachine } from "@xstate/react"
import { DEFAULT_RECORDS_PER_PAGE } from "components/PaginationWidget/PaginationWidget"
import { FC } from "react"
import { Helmet } from "react-helmet-async"
import { useSearchParams } from "react-router-dom"
import { useNavigate, useSearchParams } from "react-router-dom"
import { workspaceFilterQuery } from "util/filters"
import { pageTitle } from "util/page"
import { workspacesMachine } from "xServices/workspaces/workspacesXService"
import { WorkspacesPageView } from "./WorkspacesPageView"
const WorkspacesPage: FC = () => {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const filter = searchParams.get("filter") ?? workspaceFilterQuery.me
const currentPage = searchParams.get("page")
? Number(searchParams.get("page"))
: 1
const [workspacesState, send] = useMachine(workspacesMachine, {
context: {
page: currentPage,
limit: DEFAULT_RECORDS_PER_PAGE,
filter,
},
actions: {
onPageChange: ({ page }) => {
navigate({
search: `?page=${page}`,
})
},
},
})
const { workspaceRefs } = workspacesState.context
const {
workspaceRefs,
count,
page,
limit,
getWorkspacesError,
getCountError,
} = workspacesState.context
return (
<>
@ -28,10 +49,24 @@ const WorkspacesPage: FC = () => {
filter={workspacesState.context.filter}
isLoading={!workspaceRefs}
workspaceRefs={workspaceRefs}
count={count}
getWorkspacesError={getWorkspacesError}
getCountError={getCountError}
page={page}
limit={limit}
onNext={() => {
send("NEXT")
}}
onPrevious={() => {
send("PREVIOUS")
}}
onGoToPage={(page) => {
send("GO_TO_PAGE", { page })
}}
onFilter={(query) => {
setSearchParams({ filter: query })
send({
type: "GET_WORKSPACES",
type: "UPDATE_FILTER",
query,
})
}}

View File

@ -107,16 +107,19 @@ AllStates.args = {
...Object.values(workspaces),
...Object.values(additionalWorkspaces),
],
count: 14,
}
export const OwnerHasNoWorkspaces = Template.bind({})
OwnerHasNoWorkspaces.args = {
workspaceRefs: [],
filter: workspaceFilterQuery.me,
count: 0,
}
export const NoResults = Template.bind({})
NoResults.args = {
workspaceRefs: [],
filter: "searchtearmwithnoresults",
count: 0,
}

View File

@ -1,4 +1,7 @@
import Link from "@material-ui/core/Link"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { Maybe } from "components/Conditionals/Maybe"
import { PaginationWidget } from "components/PaginationWidget/PaginationWidget"
import { FC } from "react"
import { Link as RouterLink } from "react-router-dom"
import { Margins } from "../../components/Margins/Margins"
@ -26,13 +29,34 @@ export const Language = {
export interface WorkspacesPageViewProps {
isLoading?: boolean
workspaceRefs?: WorkspaceItemMachineRef[]
count?: number
getWorkspacesError: Error | unknown
getCountError: Error | unknown
page: number
limit: number
filter?: string
onFilter: (query: string) => void
onNext: () => void
onPrevious: () => void
onGoToPage: (page: number) => void
}
export const WorkspacesPageView: FC<
React.PropsWithChildren<WorkspacesPageViewProps>
> = ({ isLoading, workspaceRefs, filter, onFilter }) => {
> = ({
isLoading,
workspaceRefs,
count,
getWorkspacesError,
getCountError,
page,
limit,
filter,
onFilter,
onNext,
onPrevious,
onGoToPage,
}) => {
const presetFilters = [
{ query: workspaceFilterQuery.me, name: Language.yourWorkspacesButton },
{ query: workspaceFilterQuery.all, name: Language.allWorkspacesButton },
@ -61,17 +85,45 @@ export const WorkspacesPageView: FC<
</PageHeaderSubtitle>
</PageHeader>
<SearchBarWithFilter
filter={filter}
onFilter={onFilter}
presetFilters={presetFilters}
/>
<Stack>
<Maybe condition={getWorkspacesError !== undefined}>
<AlertBanner
error={getWorkspacesError}
severity={
workspaceRefs !== undefined && workspaceRefs.length > 0
? "warning"
: "error"
}
/>
</Maybe>
<Maybe condition={getCountError !== undefined}>
<AlertBanner error={getCountError} severity="warning" />
</Maybe>
<SearchBarWithFilter
filter={filter}
onFilter={onFilter}
presetFilters={presetFilters}
/>
</Stack>
<WorkspacesTable
isLoading={isLoading}
workspaceRefs={workspaceRefs}
filter={filter}
/>
<PaginationWidget
prevLabel=""
nextLabel=""
onPrevClick={onPrevious}
onNextClick={onNext}
onPageClick={onGoToPage}
numRecords={count}
activePage={page}
numRecordsPerPage={limit}
/>
</Margins>
)
}

View File

@ -424,10 +424,12 @@ export const MockWorkspace: TypesGen.Workspace = {
export const MockStoppedWorkspace: TypesGen.Workspace = {
...MockWorkspace,
id: "test-stopped-workspace",
latest_build: { ...MockWorkspaceBuildStop, status: "stopped" },
}
export const MockStoppingWorkspace: TypesGen.Workspace = {
...MockWorkspace,
id: "test-stopping-workspace",
latest_build: {
...MockWorkspaceBuildStop,
job: MockRunningProvisionerJob,
@ -436,6 +438,7 @@ export const MockStoppingWorkspace: TypesGen.Workspace = {
}
export const MockStartingWorkspace: TypesGen.Workspace = {
...MockWorkspace,
id: "test-starting-workspace",
latest_build: {
...MockWorkspaceBuild,
job: MockRunningProvisionerJob,
@ -445,6 +448,7 @@ export const MockStartingWorkspace: TypesGen.Workspace = {
}
export const MockCancelingWorkspace: TypesGen.Workspace = {
...MockWorkspace,
id: "test-canceling-workspace",
latest_build: {
...MockWorkspaceBuild,
job: MockCancelingProvisionerJob,
@ -453,6 +457,7 @@ export const MockCancelingWorkspace: TypesGen.Workspace = {
}
export const MockCanceledWorkspace: TypesGen.Workspace = {
...MockWorkspace,
id: "test-canceled-workspace",
latest_build: {
...MockWorkspaceBuild,
job: MockCanceledProvisionerJob,
@ -461,6 +466,7 @@ export const MockCanceledWorkspace: TypesGen.Workspace = {
}
export const MockFailedWorkspace: TypesGen.Workspace = {
...MockWorkspace,
id: "test-failed-workspace",
latest_build: {
...MockWorkspaceBuild,
job: MockFailedProvisionerJob,
@ -469,6 +475,7 @@ export const MockFailedWorkspace: TypesGen.Workspace = {
}
export const MockDeletingWorkspace: TypesGen.Workspace = {
...MockWorkspace,
id: "test-deleting-workspace",
latest_build: {
...MockWorkspaceBuildDelete,
job: MockRunningProvisionerJob,
@ -477,16 +484,19 @@ export const MockDeletingWorkspace: TypesGen.Workspace = {
}
export const MockDeletedWorkspace: TypesGen.Workspace = {
...MockWorkspace,
id: "test-deleted-workspace",
latest_build: { ...MockWorkspaceBuildDelete, status: "deleted" },
}
export const MockOutdatedWorkspace: TypesGen.Workspace = {
...MockFailedWorkspace,
id: "test-outdated-workspace",
outdated: true,
}
export const MockPendingWorkspace: TypesGen.Workspace = {
...MockWorkspace,
id: "test-pending-workspace",
latest_build: {
...MockWorkspaceBuild,
job: MockPendingProvisionerJob,
@ -502,6 +512,10 @@ export const MockWorkspaceRequest: TypesGen.CreateWorkspaceRequest = {
template_id: "test-template",
}
export const MockWorkspaceCountResponse: TypesGen.WorkspaceCountResponse = {
count: 26, // just over 1 page
}
export const MockUserAgent: Types.UserAgent = {
browser: "Chrome 99.0.4844",
device: "Other",

View File

@ -140,6 +140,10 @@ export const handlers = [
rest.get("/api/v2/workspaces", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json([M.MockWorkspace]))
}),
// has to come before the parameterized endpoints
rest.get("/api/v2/workspaces/count", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockWorkspaceCountResponse))
}),
rest.get("/api/v2/workspaces/:workspaceId", async (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockWorkspace))
}),

View File

@ -5,6 +5,7 @@ import { createMachine, assign } from "xstate"
export const deploymentFlagsMachine = createMachine(
{
id: "deploymentFlagsMachine",
predictableActionArguments: true,
initial: "idle",
schema: {
context: {} as {

View File

@ -206,160 +206,255 @@ interface WorkspacesContext {
workspaceRefs?: WorkspaceItemMachineRef[]
filter: string
getWorkspacesError?: Error | unknown
getCountError?: Error | unknown
page: number
count?: number
limit: number
}
type WorkspacesEvent =
| { type: "GET_WORKSPACES"; query?: string }
| { type: "UPDATE_FILTER"; query?: string }
| { type: "UPDATE_VERSION"; workspaceId: string }
| { type: "NEXT" }
| { type: "PREVIOUS" }
| { type: "GO_TO_PAGE"; page: number }
export const workspacesMachine = createMachine(
{
tsTypes: {} as import("./workspacesXService.typegen").Typegen1,
schema: {
context: {} as WorkspacesContext,
events: {} as WorkspacesEvent,
services: {} as {
getWorkspaces: {
data: TypesGen.Workspace[]
}
updateWorkspaceRefs: {
data: {
refsToKeep: WorkspaceItemMachineRef[]
newWorkspaces: TypesGen.Workspace[]
export const workspacesMachine =
/** @xstate-layout N4IgpgJg5mDOIC5QHcD2AnA1rADgQwGM4BlAFz1LADpk8BLUgFVQCUwAzdOACwHUNs+IrADEAD1jlKVPO0roAFAFYADGoCUItFlyESU6rQbM2nHvx1C4AbRUBdRKBypYDOqgB2jkGMQBGAGYANgCqFQAmAE4-cIAOOPDwoIB2ZIAaEABPRHCVKljAv2S-EMiClSUAFliAXxqM7UE9WDIKanYwUgJuOg8oKgJUAFcPUioYUlJeqABhYdGRCE9qXoA3VExqCYsm4TmR0lsHJBBnVynPb18ESsq8uOLk2KCVSqVY4qUM7IQAyqoApElOFKskgpEUn5avUQI1dMJWtIOl0en0BvMxhMpn19gswOh0BgqDgADYUdgYAC2406O3hcFxh3s3jObkuJ2ufyUVEiAXi4QCySUAWBARF3xyaiowuqZShQSCiUidQaAnpLQMVGR3WmNDVVlgVCGOAgFGmdKsplESw8Kw8602RpNbQteitRxZLjZXg5iFitwByTKARUQVulQCiQlCBeeX9sWifihhXBKth+uaiPanR1aLhBppk3NGeEi2WVDWGy2tJLNmZJ1ZFx9oGub25f0jyQqSiUQvi0aUiqoiXCfnecT8kRUwTT+czmu1qP6c+EhexUFdpZtdod1dIm5sfmOTi9TauiFukWlCb5097-tD0cqQ57dw+8cCz9ntY1bS1OaXPVLGaNdi2A0t8UJdBiTJUgKXQalth-D0G1Pdxmx8fximHF5O1uVJKgFAJoxCZIqChPlIhBUMngib9wP0P9F2mMtbSoSQ-xXRikQA6YUJPc50PPBBAhSYcQWBJJcmBfsshyIpxNHLsCiUIFIyCejdm4sARAAcQAUUYAB9XgAHkWAAaWIAAFABBGZ9OIfjTjQ9kW0QABaQiwmKadkhDQjgmSSpow8gIx3Ivkg17KIwSUPxNPVLMRAAVWsgARWzGH0oyADEAEkABlspYZzGyE30ECePwAVicLkiiJNKjHcJQvC-5Xg+Io+XHYFEoNZK0sy7KjIANX0lhiHy0yADkytcjDrj8NQyNHBUilUYplsiNrvJFJJVEHZqew0mEuN-SgRBm-SAA1GHmwS3MwkSgm5XsFQO4E-FBQI2u+sJqgaiFAeKV7+vnNoRGslh9NG6aUqc+sBO9YTBX+cFR1HQdIgjI6-o6wGojDD5QaUcGEQMPTTKMxhqbsgyHpRyr3iCKhgtE0E7gFZ58YBj4iZBkoybTDxUAgOBvHOrMaHoJhWA4LhYD4H9PUexb-DFdHe26p44hFPxo1EnkgihfzBV5QI+rOn9peYtFBgOUCcQxVWmfc35-nCVTYh9iFwpBEFo0BAEp3iCJRzUEVKnJ7T-xRXUHdGKht1ds9KpCVnXj+V5ihBD7ozWtnEnlPluZ7AIY4u7N4-tl3ULV4SPMCNm7iTZbgg+d7QqqKgXlBYIIxUSIcd5Svbd4vMfydU11wPK1U4q926vCHkdeFKiXjHJ9qjZwcffUwEVGW4XVQYqu49zZcp6xMCtPgeu3eev5pVSSdEjFRUShKbfuSFIJ339hCL2p1T533HjXK+Z9k7LAXk9a4xRPZ3F8tzMUEJoz+TeikXkipQx7wrtbM+4DL5ATvrA9WCAm6hEInEFQqQFSCjqv6IOg5d7-zeIEP4pQx4LgnlAMhjcTYtyPkmac-8hRglCt9UIfcGrdg+LQj43C2j8Mqk3IeQi26iM7hIuSFC7j-ECD7LOdVBRDzqHUIAA */
createMachine(
{
tsTypes: {} as import("./workspacesXService.typegen").Typegen1,
schema: {
context: {} as WorkspacesContext,
events: {} as WorkspacesEvent,
services: {} as {
getWorkspaces: {
data: TypesGen.Workspace[]
}
getWorkspacesCount: {
data: { count: number }
}
updateWorkspaceRefs: {
data: {
refsToKeep: WorkspaceItemMachineRef[]
newWorkspaces: TypesGen.Workspace[]
}
}
}
},
},
predictableActionArguments: true,
id: "workspacesState",
on: {
GET_WORKSPACES: {
actions: "assignFilter",
target: ".gettingWorkspaces",
internal: false,
},
UPDATE_VERSION: {
actions: "triggerUpdateVersion",
},
},
initial: "gettingWorkspaces",
states: {
gettingWorkspaces: {
entry: "clearGetWorkspacesError",
invoke: {
src: "getWorkspaces",
id: "getWorkspaces",
onDone: [
{
actions: "assignWorkspaceRefs",
cond: "isEmpty",
target: "waitToRefreshWorkspaces",
},
{
target: "updatingWorkspaceRefs",
},
],
onError: [
{
actions: "assignGetWorkspacesError",
target: "waitToRefreshWorkspaces",
},
],
},
},
updatingWorkspaceRefs: {
invoke: {
src: "updateWorkspaceRefs",
id: "updateWorkspaceRefs",
onDone: [
{
actions: "assignUpdatedWorkspaceRefs",
target: "waitToRefreshWorkspaces",
},
],
predictableActionArguments: true,
id: "workspacesState",
on: {
UPDATE_FILTER: {
target: ".fetching",
actions: ["assignFilter", "resetPage"],
},
UPDATE_VERSION: {
actions: "triggerUpdateVersion",
},
NEXT: {
target: ".fetching",
actions: ["assignNextPage", "onPageChange"],
},
PREVIOUS: {
target: ".fetching",
actions: ["assignPreviousPage", "onPageChange"],
},
GO_TO_PAGE: {
target: ".fetching",
actions: ["assignPage", "onPageChange"],
},
},
waitToRefreshWorkspaces: {
after: {
"5000": {
target: "gettingWorkspaces",
initial: "fetching",
states: {
waitToRefreshWorkspaces: {
after: {
"5000": {
target: "#workspacesState.fetching",
actions: [],
internal: false,
},
},
},
fetching: {
type: "parallel",
states: {
count: {
initial: "gettingCount",
states: {
gettingCount: {
entry: "clearGetCountError",
invoke: {
src: "getWorkspacesCount",
id: "getWorkspacesCount",
onDone: [
{
target: "done",
actions: "assignCount",
},
],
onError: [
{
target: "done",
actions: "assignGetCountError",
},
],
},
},
done: {
type: "final",
},
},
},
workspaces: {
initial: "gettingWorkspaces",
states: {
updatingWorkspaceRefs: {
invoke: {
src: "updateWorkspaceRefs",
id: "updateWorkspaceRefs",
onDone: [
{
target: "done",
actions: "assignUpdatedWorkspaceRefs",
},
],
},
},
gettingWorkspaces: {
entry: "clearGetWorkspacesError",
invoke: {
src: "getWorkspaces",
id: "getWorkspaces",
onDone: [
{
target: "done",
cond: "isEmpty",
actions: "assignWorkspaceRefs",
},
{
target: "updatingWorkspaceRefs",
},
],
onError: [
{
target: "done",
actions: "assignGetWorkspacesError",
},
],
},
},
done: {
type: "final",
},
},
},
},
onDone: {
target: "waitToRefreshWorkspaces",
},
},
},
},
},
{
guards: {
isEmpty: (context) => !context.workspaceRefs,
},
actions: {
assignWorkspaceRefs: assign({
workspaceRefs: (_, event) =>
event.data.map((data) => {
return spawn(workspaceItemMachine.withContext({ data }), data.id)
}),
}),
assignFilter: assign({
filter: (context, event) => event.query ?? context.filter,
}),
assignGetWorkspacesError: assign({
getWorkspacesError: (_, event) => event.data,
}),
clearGetWorkspacesError: (context) =>
assign({ ...context, getWorkspacesError: undefined }),
triggerUpdateVersion: (context, event) => {
const workspaceRef = context.workspaceRefs?.find(
(ref) => ref.id === event.workspaceId,
)
if (!workspaceRef) {
throw new Error(`No workspace ref found for ${event.workspaceId}.`)
}
workspaceRef.send("UPDATE_VERSION")
{
guards: {
isEmpty: (context) => !context.workspaceRefs,
},
assignUpdatedWorkspaceRefs: assign({
workspaceRefs: (_, event) => {
const newWorkspaceRefs = event.data.newWorkspaces.map((workspace) =>
spawn(
workspaceItemMachine.withContext({ data: workspace }),
workspace.id,
),
actions: {
assignWorkspaceRefs: assign({
workspaceRefs: (_, event) =>
event.data.map((data) => {
return spawn(workspaceItemMachine.withContext({ data }), data.id)
}),
}),
assignFilter: assign({
filter: (context, event) => event.query ?? context.filter,
}),
assignGetWorkspacesError: assign({
getWorkspacesError: (_, event) => event.data,
}),
clearGetWorkspacesError: (context) =>
assign({ ...context, getWorkspacesError: undefined }),
triggerUpdateVersion: (context, event) => {
const workspaceRef = context.workspaceRefs?.find(
(ref) => ref.id === event.workspaceId,
)
return event.data.refsToKeep.concat(newWorkspaceRefs)
},
}),
},
services: {
getWorkspaces: (context) =>
API.getWorkspaces(queryToFilter(context.filter)),
updateWorkspaceRefs: (context, event) => {
const refsToKeep: WorkspaceItemMachineRef[] = []
context.workspaceRefs?.forEach((ref) => {
const matchingWorkspace = event.data.find(
(workspace) => ref.id === workspace.id,
)
if (matchingWorkspace) {
// if a workspace machine reference describes a workspace that has not been deleted,
// update its data and mark it as a refToKeep
ref.send({ type: "UPDATE_DATA", data: matchingWorkspace })
refsToKeep.push(ref)
} else {
// if it describes a workspace that has been deleted, stop the machine
ref.stop && ref.stop()
if (!workspaceRef) {
throw new Error(`No workspace ref found for ${event.workspaceId}.`)
}
})
const newWorkspaces = event.data.filter(
(workspace) =>
!context.workspaceRefs?.find((ref) => ref.id === workspace.id),
)
workspaceRef.send("UPDATE_VERSION")
},
assignUpdatedWorkspaceRefs: assign({
workspaceRefs: (_, event) => {
const newWorkspaceRefs = event.data.newWorkspaces.map((workspace) =>
spawn(
workspaceItemMachine.withContext({ data: workspace }),
workspace.id,
),
)
return event.data.refsToKeep.concat(newWorkspaceRefs)
},
}),
assignNextPage: assign({
page: (context) => context.page + 1,
}),
assignPreviousPage: assign({
page: (context) => context.page - 1,
}),
assignPage: assign({
page: (_, event) => event.page,
}),
resetPage: assign({
page: (_) => 1,
}),
assignCount: assign({
count: (_, event) => event.data.count,
}),
assignGetCountError: assign({
getCountError: (_, event) => event.data,
}),
clearGetCountError: assign({
getCountError: (_) => undefined,
}),
},
services: {
getWorkspaces: (context) =>
API.getWorkspaces({
...queryToFilter(context.filter),
offset: (context.page - 1) * context.limit,
limit: context.limit,
}),
updateWorkspaceRefs: (context, event) => {
const refsToKeep: WorkspaceItemMachineRef[] = []
context.workspaceRefs?.forEach((ref) => {
const matchingWorkspace = event.data.find(
(workspace) => ref.id === workspace.id,
)
if (matchingWorkspace) {
// if a workspace machine reference describes a workspace that has not been deleted,
// update its data and mark it as a refToKeep
ref.send({ type: "UPDATE_DATA", data: matchingWorkspace })
refsToKeep.push(ref)
} else {
// if it describes a workspace that has been deleted, stop the machine
ref.stop && ref.stop()
}
})
return Promise.resolve({
refsToKeep,
newWorkspaces,
})
const newWorkspaces = event.data.filter(
(workspace) =>
!context.workspaceRefs?.find((ref) => ref.id === workspace.id),
)
return Promise.resolve({
refsToKeep,
newWorkspaces,
})
},
getWorkspacesCount: (context) =>
API.getWorkspacesCount({ q: context.filter }),
},
},
},
)
)