mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add impending deletion filter to workspaces page (#7860)
* add workspace deletion dialog * add deleting_by query param * added test * filtering on workspaces to be deleted * cleaned up form * added story * added banner filter * PR feedback * fix lint and stories * PR feedback * added enterprise test * added unit tests in search_test.go * remove unused fn * unstaged changes
This commit is contained in:
Generated
+6
@@ -5229,6 +5229,12 @@ const docTemplate = `{
|
|||||||
"description": "Filter by agent status",
|
"description": "Filter by agent status",
|
||||||
"name": "has_agent",
|
"name": "has_agent",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Filter workspaces scheduled to be deleted by this time",
|
||||||
|
"name": "deleting_by",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|||||||
Generated
+6
@@ -4604,6 +4604,12 @@
|
|||||||
"description": "Filter by agent status",
|
"description": "Filter by agent status",
|
||||||
"name": "has_agent",
|
"name": "has_agent",
|
||||||
"in": "query"
|
"in": "query"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "string",
|
||||||
|
"description": "Filter workspaces scheduled to be deleted by this time",
|
||||||
|
"name": "deleting_by",
|
||||||
|
"in": "query"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"github.com/coder/coder/coderd/database"
|
"github.com/coder/coder/coderd/database"
|
||||||
"github.com/coder/coder/coderd/httpapi"
|
"github.com/coder/coder/coderd/httpapi"
|
||||||
|
"github.com/coder/coder/coderd/util/ptr"
|
||||||
"github.com/coder/coder/codersdk"
|
"github.com/coder/coder/codersdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,7 +67,11 @@ func Users(query string) (database.GetUsersParams, []codersdk.ValidationError) {
|
|||||||
return filter, parser.Errors
|
return filter, parser.Errors
|
||||||
}
|
}
|
||||||
|
|
||||||
func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectTimeout time.Duration) (database.GetWorkspacesParams, []codersdk.ValidationError) {
|
type PostFilter struct {
|
||||||
|
DeletingBy *time.Time `json:"deleting_by" format:"date-time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectTimeout time.Duration) (database.GetWorkspacesParams, PostFilter, []codersdk.ValidationError) {
|
||||||
filter := database.GetWorkspacesParams{
|
filter := database.GetWorkspacesParams{
|
||||||
AgentInactiveDisconnectTimeoutSeconds: int64(agentInactiveDisconnectTimeout.Seconds()),
|
AgentInactiveDisconnectTimeoutSeconds: int64(agentInactiveDisconnectTimeout.Seconds()),
|
||||||
|
|
||||||
@@ -74,8 +79,10 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT
|
|||||||
Limit: int32(page.Limit),
|
Limit: int32(page.Limit),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var postFilter PostFilter
|
||||||
|
|
||||||
if query == "" {
|
if query == "" {
|
||||||
return filter, nil
|
return filter, postFilter, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always lowercase for all searches.
|
// Always lowercase for all searches.
|
||||||
@@ -95,7 +102,7 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if len(errors) > 0 {
|
if len(errors) > 0 {
|
||||||
return filter, errors
|
return filter, postFilter, errors
|
||||||
}
|
}
|
||||||
|
|
||||||
parser := httpapi.NewQueryParamParser()
|
parser := httpapi.NewQueryParamParser()
|
||||||
@@ -104,8 +111,13 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT
|
|||||||
filter.Name = parser.String(values, "", "name")
|
filter.Name = parser.String(values, "", "name")
|
||||||
filter.Status = string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[database.WorkspaceStatus]))
|
filter.Status = string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[database.WorkspaceStatus]))
|
||||||
filter.HasAgent = parser.String(values, "", "has-agent")
|
filter.HasAgent = parser.String(values, "", "has-agent")
|
||||||
|
|
||||||
|
if _, ok := values["deleting_by"]; ok {
|
||||||
|
postFilter.DeletingBy = ptr.Ref(parser.Time(values, time.Time{}, "deleting_by", "2006-01-02"))
|
||||||
|
}
|
||||||
|
|
||||||
parser.ErrorExcessParams(values)
|
parser.ErrorExcessParams(values)
|
||||||
return filter, parser.Errors
|
return filter, postFilter, parser.Errors
|
||||||
}
|
}
|
||||||
|
|
||||||
func searchTerms(query string, defaultKey func(term string, values url.Values) error) (url.Values, []codersdk.ValidationError) {
|
func searchTerms(query string, defaultKey func(term string, values url.Values) error) (url.Values, []codersdk.ValidationError) {
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/coder/coder/coderd/database"
|
"github.com/coder/coder/coderd/database"
|
||||||
"github.com/coder/coder/coderd/rbac"
|
"github.com/coder/coder/coderd/rbac"
|
||||||
"github.com/coder/coder/coderd/searchquery"
|
"github.com/coder/coder/coderd/searchquery"
|
||||||
|
"github.com/coder/coder/coderd/util/ptr"
|
||||||
"github.com/coder/coder/codersdk"
|
"github.com/coder/coder/codersdk"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -148,17 +150,18 @@ func TestSearchWorkspace(t *testing.T) {
|
|||||||
c := c
|
c := c
|
||||||
t.Run(c.Name, func(t *testing.T) {
|
t.Run(c.Name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
values, errs := searchquery.Workspaces(c.Query, codersdk.Pagination{}, 0)
|
values, postFilter, errs := searchquery.Workspaces(c.Query, codersdk.Pagination{}, 0)
|
||||||
if c.ExpectedErrorContains != "" {
|
if c.ExpectedErrorContains != "" {
|
||||||
require.True(t, len(errs) > 0, "expect some errors")
|
assert.True(t, len(errs) > 0, "expect some errors")
|
||||||
var s strings.Builder
|
var s strings.Builder
|
||||||
for _, err := range errs {
|
for _, err := range errs {
|
||||||
_, _ = s.WriteString(fmt.Sprintf("%s: %s\n", err.Field, err.Detail))
|
_, _ = s.WriteString(fmt.Sprintf("%s: %s\n", err.Field, err.Detail))
|
||||||
}
|
}
|
||||||
require.Contains(t, s.String(), c.ExpectedErrorContains)
|
assert.Contains(t, s.String(), c.ExpectedErrorContains)
|
||||||
} else {
|
} else {
|
||||||
require.Len(t, errs, 0, "expected no error")
|
assert.Empty(t, postFilter)
|
||||||
require.Equal(t, c.Expected, values, "expected values")
|
assert.Len(t, errs, 0, "expected no error")
|
||||||
|
assert.Equal(t, c.Expected, values, "expected values")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -167,10 +170,51 @@ func TestSearchWorkspace(t *testing.T) {
|
|||||||
|
|
||||||
query := ``
|
query := ``
|
||||||
timeout := 1337 * time.Second
|
timeout := 1337 * time.Second
|
||||||
values, errs := searchquery.Workspaces(query, codersdk.Pagination{}, timeout)
|
values, _, errs := searchquery.Workspaces(query, codersdk.Pagination{}, timeout)
|
||||||
require.Empty(t, errs)
|
require.Empty(t, errs)
|
||||||
require.Equal(t, int64(timeout.Seconds()), values.AgentInactiveDisconnectTimeoutSeconds)
|
require.Equal(t, int64(timeout.Seconds()), values.AgentInactiveDisconnectTimeoutSeconds)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("TestSearchWorkspacePostFilter", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
testCases := []struct {
|
||||||
|
Name string
|
||||||
|
Query string
|
||||||
|
Expected searchquery.PostFilter
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "Empty",
|
||||||
|
Query: "",
|
||||||
|
Expected: searchquery.PostFilter{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DeletingBy",
|
||||||
|
Query: "deleting_by:2023-06-09",
|
||||||
|
Expected: searchquery.PostFilter{
|
||||||
|
DeletingBy: ptr.Ref(time.Date(
|
||||||
|
2023, 6, 9, 0, 0, 0, 0, time.UTC)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "MultipleParams",
|
||||||
|
Query: "deleting_by:2023-06-09 name:workspace-name",
|
||||||
|
Expected: searchquery.PostFilter{
|
||||||
|
DeletingBy: ptr.Ref(time.Date(
|
||||||
|
2023, 6, 9, 0, 0, 0, 0, time.UTC)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range testCases {
|
||||||
|
c := c
|
||||||
|
t.Run(c.Name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
_, postFilter, errs := searchquery.Workspaces(c.Query, codersdk.Pagination{}, 0)
|
||||||
|
assert.Len(t, errs, 0, "expected no error")
|
||||||
|
assert.Equal(t, c.Expected, postFilter, "expected values")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSearchAudit(t *testing.T) {
|
func TestSearchAudit(t *testing.T) {
|
||||||
|
|||||||
+21
-2
@@ -106,6 +106,7 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) {
|
|||||||
// @Param name query string false "Filter with partial-match by workspace name"
|
// @Param name query string false "Filter with partial-match by workspace name"
|
||||||
// @Param status query string false "Filter by workspace status" Enums(pending,running,stopping,stopped,failed,canceling,canceled,deleted,deleting)
|
// @Param status query string false "Filter by workspace status" Enums(pending,running,stopping,stopped,failed,canceling,canceled,deleted,deleting)
|
||||||
// @Param has_agent query string false "Filter by agent status" Enums(connected,connecting,disconnected,timeout)
|
// @Param has_agent query string false "Filter by agent status" Enums(connected,connecting,disconnected,timeout)
|
||||||
|
// @Param deleting_by query string false "Filter workspaces scheduled to be deleted by this time"
|
||||||
// @Success 200 {object} codersdk.WorkspacesResponse
|
// @Success 200 {object} codersdk.WorkspacesResponse
|
||||||
// @Router /workspaces [get]
|
// @Router /workspaces [get]
|
||||||
func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
|
func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
|
||||||
@@ -118,7 +119,7 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
queryStr := r.URL.Query().Get("q")
|
queryStr := r.URL.Query().Get("q")
|
||||||
filter, errs := searchquery.Workspaces(queryStr, page, api.AgentInactiveDisconnectTimeout)
|
filter, postFilter, errs := searchquery.Workspaces(queryStr, page, api.AgentInactiveDisconnectTimeout)
|
||||||
if len(errs) > 0 {
|
if len(errs) > 0 {
|
||||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||||
Message: "Invalid workspace search query.",
|
Message: "Invalid workspace search query.",
|
||||||
@@ -178,8 +179,26 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var filteredWorkspaces []codersdk.Workspace
|
||||||
|
// apply post filters, if they exist
|
||||||
|
if postFilter.DeletingBy == nil {
|
||||||
|
filteredWorkspaces = append(filteredWorkspaces, wss...)
|
||||||
|
} else {
|
||||||
|
for _, v := range wss {
|
||||||
|
if v.DeletingAt == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// get the beginning of the day on which deletion is scheduled
|
||||||
|
truncatedDeletionAt := time.Date(v.DeletingAt.Year(), v.DeletingAt.Month(), v.DeletingAt.Day(), 0, 0, 0, 0, v.DeletingAt.Location())
|
||||||
|
if truncatedDeletionAt.After(*postFilter.DeletingBy) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filteredWorkspaces = append(filteredWorkspaces, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspacesResponse{
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspacesResponse{
|
||||||
Workspaces: wss,
|
Workspaces: filteredWorkspaces,
|
||||||
Count: int(workspaceRows[0].Count),
|
Count: int(workspaceRows[0].Count),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -1209,6 +1210,62 @@ func TestWorkspaceFilterManual(t *testing.T) {
|
|||||||
return workspaces.Count == 1
|
return workspaces.Count == 1
|
||||||
}, testutil.IntervalMedium, "agent status timeout")
|
}, testutil.IntervalMedium, "agent status timeout")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("FilterQueryHasDeletingByAndUnlicensed", func(t *testing.T) {
|
||||||
|
// this test has a licensed counterpart in enterprise/coderd/workspaces_test.go: FilterQueryHasDeletingByAndLicensed
|
||||||
|
t.Parallel()
|
||||||
|
inactivityTTL := 1 * 24 * time.Hour
|
||||||
|
var setCalled int64
|
||||||
|
|
||||||
|
client := coderdtest.New(t, &coderdtest.Options{
|
||||||
|
IncludeProvisionerDaemon: true,
|
||||||
|
TemplateScheduleStore: schedule.MockTemplateScheduleStore{
|
||||||
|
SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) {
|
||||||
|
if atomic.AddInt64(&setCalled, 1) == 2 {
|
||||||
|
assert.Equal(t, inactivityTTL, options.InactivityTTL)
|
||||||
|
}
|
||||||
|
template.InactivityTTL = int64(options.InactivityTTL)
|
||||||
|
return template, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
user := coderdtest.CreateFirstUser(t, client)
|
||||||
|
authToken := uuid.NewString()
|
||||||
|
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||||
|
Parse: echo.ParseComplete,
|
||||||
|
ProvisionPlan: echo.ProvisionComplete,
|
||||||
|
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
|
||||||
|
})
|
||||||
|
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||||
|
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||||
|
|
||||||
|
// update template with inactivity ttl
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
template, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{
|
||||||
|
InactivityTTLMillis: inactivityTTL.Milliseconds(),
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, inactivityTTL.Milliseconds(), template.InactivityTTLMillis)
|
||||||
|
|
||||||
|
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||||
|
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||||
|
|
||||||
|
// stop build so workspace is inactive
|
||||||
|
stopBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop)
|
||||||
|
coderdtest.AwaitWorkspaceBuildJob(t, client, stopBuild.ID)
|
||||||
|
|
||||||
|
res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||||
|
FilterQuery: fmt.Sprintf("deleting_by:%s", time.Now().Add(inactivityTTL).Format("2006-01-02")),
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
// we are expecting that no workspaces are returned as user is unlicensed
|
||||||
|
// and template.InactivityTTL should be 0
|
||||||
|
assert.Len(t, res.Workspaces, 0)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOffsetLimit(t *testing.T) {
|
func TestOffsetLimit(t *testing.T) {
|
||||||
|
|||||||
@@ -383,13 +383,14 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \
|
|||||||
|
|
||||||
### Parameters
|
### Parameters
|
||||||
|
|
||||||
| Name | In | Type | Required | Description |
|
| Name | In | Type | Required | Description |
|
||||||
| ----------- | ----- | ------ | -------- | ------------------------------------------- |
|
| ------------- | ----- | ------ | -------- | ------------------------------------------------------ |
|
||||||
| `owner` | query | string | false | Filter by owner username |
|
| `owner` | query | string | false | Filter by owner username |
|
||||||
| `template` | query | string | false | Filter by template name |
|
| `template` | query | string | false | Filter by template name |
|
||||||
| `name` | query | string | false | Filter with partial-match by workspace name |
|
| `name` | query | string | false | Filter with partial-match by workspace name |
|
||||||
| `status` | query | string | false | Filter by workspace status |
|
| `status` | query | string | false | Filter by workspace status |
|
||||||
| `has_agent` | query | string | false | Filter by agent status |
|
| `has_agent` | query | string | false | Filter by agent status |
|
||||||
|
| `deleting_by` | query | string | false | Filter workspaces scheduled to be deleted by this time |
|
||||||
|
|
||||||
#### Enumerated Values
|
#### Enumerated Values
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ package coderd_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/coder/coder/coderd/coderdtest"
|
"github.com/coder/coder/coderd/coderdtest"
|
||||||
|
"github.com/coder/coder/coderd/database"
|
||||||
"github.com/coder/coder/coderd/util/ptr"
|
"github.com/coder/coder/coderd/util/ptr"
|
||||||
"github.com/coder/coder/codersdk"
|
"github.com/coder/coder/codersdk"
|
||||||
"github.com/coder/coder/enterprise/coderd/coderdenttest"
|
"github.com/coder/coder/enterprise/coderd/coderdenttest"
|
||||||
@@ -70,3 +73,56 @@ func TestCreateWorkspace(t *testing.T) {
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorkspacesFiltering(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("FilterQueryHasDeletingByAndLicensed", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
inactivityTTL := 1 * 24 * time.Hour
|
||||||
|
|
||||||
|
client := coderdenttest.New(t, &coderdenttest.Options{
|
||||||
|
Options: &coderdtest.Options{
|
||||||
|
IncludeProvisionerDaemon: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
user := coderdtest.CreateFirstUser(t, client)
|
||||||
|
_ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
|
||||||
|
Features: license.Features{
|
||||||
|
codersdk.FeatureAdvancedTemplateScheduling: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||||
|
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||||
|
|
||||||
|
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||||
|
|
||||||
|
// update template with inactivity ttl
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
template, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{
|
||||||
|
InactivityTTLMillis: inactivityTTL.Milliseconds(),
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, inactivityTTL.Milliseconds(), template.InactivityTTLMillis)
|
||||||
|
|
||||||
|
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||||
|
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||||
|
|
||||||
|
// stop build so workspace is inactive
|
||||||
|
stopBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop)
|
||||||
|
coderdtest.AwaitWorkspaceBuildJob(t, client, stopBuild.ID)
|
||||||
|
|
||||||
|
res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||||
|
// adding a second to time.Now() to give some buffer in case test runs quickly
|
||||||
|
FilterQuery: fmt.Sprintf("deleting_by:%s", time.Now().Add(time.Second).Add(inactivityTTL).Format("2006-01-02")),
|
||||||
|
})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, res.Workspaces, 1)
|
||||||
|
assert.Equal(t, workspace.ID, res.Workspaces[0].ID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { Workspace } from "api/typesGenerated"
|
|||||||
import { displayImpendingDeletion } from "./utils"
|
import { displayImpendingDeletion } from "./utils"
|
||||||
import { useDashboard } from "components/Dashboard/DashboardProvider"
|
import { useDashboard } from "components/Dashboard/DashboardProvider"
|
||||||
import { Alert } from "components/Alert/Alert"
|
import { Alert } from "components/Alert/Alert"
|
||||||
import { formatDistanceToNow, differenceInDays } from "date-fns"
|
import { formatDistanceToNow, differenceInDays, add, format } from "date-fns"
|
||||||
|
import Link from "@mui/material/Link"
|
||||||
|
import { Link as RouterLink } from "react-router-dom"
|
||||||
|
|
||||||
export enum Count {
|
export enum Count {
|
||||||
Singular,
|
Singular,
|
||||||
@@ -46,17 +48,34 @@ export const ImpendingDeletionBanner = ({
|
|||||||
new Date(),
|
new Date(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const plusFourteen = add(new Date(), { days: 14 })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Alert
|
<Alert
|
||||||
severity={daysUntilDelete <= 7 ? "warning" : "info"}
|
severity={daysUntilDelete <= 7 ? "warning" : "info"}
|
||||||
onDismiss={onDismiss}
|
onDismiss={onDismiss}
|
||||||
dismissible
|
dismissible
|
||||||
>
|
>
|
||||||
{count === Count.Singular
|
{count === Count.Singular ? (
|
||||||
? `This workspace has been unused for ${formatDistanceToNow(
|
`This workspace has been unused for ${formatDistanceToNow(
|
||||||
Date.parse(workspace.last_used_at),
|
Date.parse(workspace.last_used_at),
|
||||||
)} and is scheduled for deletion. To keep it, connect via SSH or the web terminal.`
|
)} and is scheduled for deletion. To keep it, connect via SSH or the web terminal.`
|
||||||
: "You have workspaces that will be deleted soon due to inactivity. To keep these workspaces, connect to them via SSH or the web terminal."}
|
) : (
|
||||||
|
<>
|
||||||
|
<span>There are</span>{" "}
|
||||||
|
<Link
|
||||||
|
component={RouterLink}
|
||||||
|
to={`/workspaces?filter=deleting_by:${format(
|
||||||
|
plusFourteen,
|
||||||
|
"y-MM-dd",
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
workspaces
|
||||||
|
</Link>{" "}
|
||||||
|
that will be deleted soon due to inactivity. To keep these workspaces,
|
||||||
|
connect to them via SSH or the web terminal.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Alert>
|
</Alert>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/react"
|
||||||
|
import { InactivityDialog } from "./InactivityDialog"
|
||||||
|
|
||||||
|
const meta: Meta<typeof InactivityDialog> = {
|
||||||
|
title: "InactivityDialog",
|
||||||
|
component: InactivityDialog,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof InactivityDialog>
|
||||||
|
|
||||||
|
export const OpenDialog: Story = {
|
||||||
|
args: {
|
||||||
|
submitValues: () => null,
|
||||||
|
isInactivityDialogOpen: true,
|
||||||
|
setIsInactivityDialogOpen: () => null,
|
||||||
|
workspacesToBeDeletedToday: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"
|
||||||
|
|
||||||
|
export const InactivityDialog = ({
|
||||||
|
submitValues,
|
||||||
|
isInactivityDialogOpen,
|
||||||
|
setIsInactivityDialogOpen,
|
||||||
|
workspacesToBeDeletedToday,
|
||||||
|
}: {
|
||||||
|
submitValues: () => void
|
||||||
|
isInactivityDialogOpen: boolean
|
||||||
|
setIsInactivityDialogOpen: (arg0: boolean) => void
|
||||||
|
workspacesToBeDeletedToday: number
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<ConfirmDialog
|
||||||
|
type="delete"
|
||||||
|
open={isInactivityDialogOpen}
|
||||||
|
onConfirm={() => {
|
||||||
|
submitValues()
|
||||||
|
setIsInactivityDialogOpen(false)
|
||||||
|
}}
|
||||||
|
onClose={() => setIsInactivityDialogOpen(false)}
|
||||||
|
title="Delete inactive workspaces"
|
||||||
|
confirmText="Delete Workspaces"
|
||||||
|
description={`There are ${
|
||||||
|
workspacesToBeDeletedToday ? workspacesToBeDeletedToday : ""
|
||||||
|
} workspaces that already match this filter and will be deleted upon form submission. Are you sure you want to proceed?`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
import { Maybe } from "components/Conditionals/Maybe"
|
||||||
|
import { useTranslation } from "react-i18next"
|
||||||
|
|
||||||
|
export const TTLHelperText = ({
|
||||||
|
ttl,
|
||||||
|
translationName,
|
||||||
|
}: {
|
||||||
|
ttl?: number
|
||||||
|
translationName: string
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation("templateSettingsPage")
|
||||||
|
const count = typeof ttl !== "number" ? 0 : ttl
|
||||||
|
return (
|
||||||
|
// no helper text if ttl is negative - error will show once field is considered touched
|
||||||
|
<Maybe condition={count >= 0}>
|
||||||
|
<span>{t(translationName, { count })}</span>
|
||||||
|
</Maybe>
|
||||||
|
)
|
||||||
|
}
|
||||||
+47
-104
@@ -1,12 +1,9 @@
|
|||||||
import TextField from "@mui/material/TextField"
|
import TextField from "@mui/material/TextField"
|
||||||
import { Template, UpdateTemplateMeta } from "api/typesGenerated"
|
import { Template, UpdateTemplateMeta } from "api/typesGenerated"
|
||||||
import { FormikTouched, useFormik } from "formik"
|
import { FormikTouched, useFormik } from "formik"
|
||||||
import { FC, ChangeEvent } from "react"
|
import { FC, ChangeEvent, useState } from "react"
|
||||||
import { getFormHelpers } from "utils/formUtils"
|
import { getFormHelpers } from "utils/formUtils"
|
||||||
import * as Yup from "yup"
|
|
||||||
import i18next from "i18next"
|
|
||||||
import { useTranslation } from "react-i18next"
|
import { useTranslation } from "react-i18next"
|
||||||
import { Maybe } from "components/Conditionals/Maybe"
|
|
||||||
import {
|
import {
|
||||||
FormSection,
|
FormSection,
|
||||||
HorizontalForm,
|
HorizontalForm,
|
||||||
@@ -19,93 +16,16 @@ import Link from "@mui/material/Link"
|
|||||||
import Checkbox from "@mui/material/Checkbox"
|
import Checkbox from "@mui/material/Checkbox"
|
||||||
import FormControlLabel from "@mui/material/FormControlLabel"
|
import FormControlLabel from "@mui/material/FormControlLabel"
|
||||||
import Switch from "@mui/material/Switch"
|
import Switch from "@mui/material/Switch"
|
||||||
|
import { InactivityDialog } from "./InactivityDialog"
|
||||||
|
import { useWorkspacesToBeDeleted } from "./useWorkspacesToBeDeleted"
|
||||||
|
import { TemplateScheduleFormValues, getValidationSchema } from "./formHelpers"
|
||||||
|
import { TTLHelperText } from "./TTLHelperText"
|
||||||
|
|
||||||
const TTLHelperText = ({
|
|
||||||
ttl,
|
|
||||||
translationName,
|
|
||||||
}: {
|
|
||||||
ttl?: number
|
|
||||||
translationName: string
|
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation("templateSettingsPage")
|
|
||||||
const count = typeof ttl !== "number" ? 0 : ttl
|
|
||||||
return (
|
|
||||||
// no helper text if ttl is negative - error will show once field is considered touched
|
|
||||||
<Maybe condition={count >= 0}>
|
|
||||||
<span>{t(translationName, { count })}</span>
|
|
||||||
</Maybe>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_TTL_DAYS = 7
|
|
||||||
const MS_HOUR_CONVERSION = 3600000
|
const MS_HOUR_CONVERSION = 3600000
|
||||||
const MS_DAY_CONVERSION = 86400000
|
const MS_DAY_CONVERSION = 86400000
|
||||||
const FAILURE_CLEANUP_DEFAULT = 7
|
const FAILURE_CLEANUP_DEFAULT = 7
|
||||||
const INACTIVITY_CLEANUP_DEFAULT = 180
|
const INACTIVITY_CLEANUP_DEFAULT = 180
|
||||||
|
|
||||||
export interface TemplateScheduleFormValues extends UpdateTemplateMeta {
|
|
||||||
failure_cleanup_enabled: boolean
|
|
||||||
inactivity_cleanup_enabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getValidationSchema = (): Yup.AnyObjectSchema =>
|
|
||||||
Yup.object({
|
|
||||||
default_ttl_ms: Yup.number()
|
|
||||||
.integer()
|
|
||||||
.min(
|
|
||||||
0,
|
|
||||||
i18next
|
|
||||||
.t("defaultTTLMinError", { ns: "templateSettingsPage" })
|
|
||||||
.toString(),
|
|
||||||
)
|
|
||||||
.max(
|
|
||||||
24 * MAX_TTL_DAYS /* 7 days in hours */,
|
|
||||||
i18next
|
|
||||||
.t("defaultTTLMaxError", { ns: "templateSettingsPage" })
|
|
||||||
.toString(),
|
|
||||||
),
|
|
||||||
max_ttl_ms: Yup.number()
|
|
||||||
.integer()
|
|
||||||
.min(
|
|
||||||
0,
|
|
||||||
i18next.t("maxTTLMinError", { ns: "templateSettingsPage" }).toString(),
|
|
||||||
)
|
|
||||||
.max(
|
|
||||||
24 * MAX_TTL_DAYS /* 7 days in hours */,
|
|
||||||
i18next.t("maxTTLMaxError", { ns: "templateSettingsPage" }).toString(),
|
|
||||||
),
|
|
||||||
failure_ttl_ms: Yup.number()
|
|
||||||
.min(0, "Failure cleanup days must not be less than 0.")
|
|
||||||
.test(
|
|
||||||
"positive-if-enabled",
|
|
||||||
"Failure cleanup days must be greater than zero when enabled.",
|
|
||||||
function (value) {
|
|
||||||
const parent = this.parent as TemplateScheduleFormValues
|
|
||||||
if (parent.failure_cleanup_enabled) {
|
|
||||||
return Boolean(value)
|
|
||||||
} else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
inactivity_ttl_ms: Yup.number()
|
|
||||||
.min(0, "Inactivity cleanup days must not be less than 0.")
|
|
||||||
.test(
|
|
||||||
"positive-if-enabled",
|
|
||||||
"Inactivity cleanup days must be greater than zero when enabled.",
|
|
||||||
function (value) {
|
|
||||||
const parent = this.parent as TemplateScheduleFormValues
|
|
||||||
if (parent.inactivity_cleanup_enabled) {
|
|
||||||
return Boolean(value)
|
|
||||||
} else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
allow_user_autostart: Yup.boolean(),
|
|
||||||
allow_user_autostop: Yup.boolean(),
|
|
||||||
})
|
|
||||||
|
|
||||||
export interface TemplateScheduleForm {
|
export interface TemplateScheduleForm {
|
||||||
template: Template
|
template: Template
|
||||||
onSubmit: (data: UpdateTemplateMeta) => void
|
onSubmit: (data: UpdateTemplateMeta) => void
|
||||||
@@ -154,25 +74,16 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
|
|||||||
allowAdvancedScheduling && Boolean(template.inactivity_ttl_ms),
|
allowAdvancedScheduling && Boolean(template.inactivity_ttl_ms),
|
||||||
},
|
},
|
||||||
validationSchema,
|
validationSchema,
|
||||||
onSubmit: (formData) => {
|
onSubmit: () => {
|
||||||
// on submit, convert from hours => ms
|
if (
|
||||||
onSubmit({
|
form.values.inactivity_cleanup_enabled &&
|
||||||
default_ttl_ms: formData.default_ttl_ms
|
workspacesToBeDeletedToday &&
|
||||||
? formData.default_ttl_ms * MS_HOUR_CONVERSION
|
workspacesToBeDeletedToday.length > 0
|
||||||
: undefined,
|
) {
|
||||||
max_ttl_ms: formData.max_ttl_ms
|
setIsInactivityDialogOpen(true)
|
||||||
? formData.max_ttl_ms * MS_HOUR_CONVERSION
|
} else {
|
||||||
: undefined,
|
submitValues()
|
||||||
failure_ttl_ms: formData.failure_ttl_ms
|
}
|
||||||
? formData.failure_ttl_ms * MS_DAY_CONVERSION
|
|
||||||
: undefined,
|
|
||||||
inactivity_ttl_ms: formData.inactivity_ttl_ms
|
|
||||||
? formData.inactivity_ttl_ms * MS_DAY_CONVERSION
|
|
||||||
: undefined,
|
|
||||||
|
|
||||||
allow_user_autostart: formData.allow_user_autostart,
|
|
||||||
allow_user_autostop: formData.allow_user_autostop,
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
initialTouched,
|
initialTouched,
|
||||||
})
|
})
|
||||||
@@ -183,6 +94,32 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
|
|||||||
const { t } = useTranslation("templateSettingsPage")
|
const { t } = useTranslation("templateSettingsPage")
|
||||||
const styles = useStyles()
|
const styles = useStyles()
|
||||||
|
|
||||||
|
const workspacesToBeDeletedToday = useWorkspacesToBeDeleted(form.values)
|
||||||
|
|
||||||
|
const [isInactivityDialogOpen, setIsInactivityDialogOpen] =
|
||||||
|
useState<boolean>(false)
|
||||||
|
|
||||||
|
const submitValues = () => {
|
||||||
|
// on submit, convert from hours => ms
|
||||||
|
onSubmit({
|
||||||
|
default_ttl_ms: form.values.default_ttl_ms
|
||||||
|
? form.values.default_ttl_ms * MS_HOUR_CONVERSION
|
||||||
|
: undefined,
|
||||||
|
max_ttl_ms: form.values.max_ttl_ms
|
||||||
|
? form.values.max_ttl_ms * MS_HOUR_CONVERSION
|
||||||
|
: undefined,
|
||||||
|
failure_ttl_ms: form.values.failure_ttl_ms
|
||||||
|
? form.values.failure_ttl_ms * MS_DAY_CONVERSION
|
||||||
|
: undefined,
|
||||||
|
inactivity_ttl_ms: form.values.inactivity_ttl_ms
|
||||||
|
? form.values.inactivity_ttl_ms * MS_DAY_CONVERSION
|
||||||
|
: undefined,
|
||||||
|
|
||||||
|
allow_user_autostart: form.values.allow_user_autostart,
|
||||||
|
allow_user_autostop: form.values.allow_user_autostop,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleToggleFailureCleanup = async (e: ChangeEvent) => {
|
const handleToggleFailureCleanup = async (e: ChangeEvent) => {
|
||||||
form.handleChange(e)
|
form.handleChange(e)
|
||||||
if (!form.values.failure_cleanup_enabled) {
|
if (!form.values.failure_cleanup_enabled) {
|
||||||
@@ -395,6 +332,12 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
|
|||||||
</FormSection>
|
</FormSection>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<InactivityDialog
|
||||||
|
submitValues={submitValues}
|
||||||
|
isInactivityDialogOpen={isInactivityDialogOpen}
|
||||||
|
setIsInactivityDialogOpen={setIsInactivityDialogOpen}
|
||||||
|
workspacesToBeDeletedToday={workspacesToBeDeletedToday?.length ?? 0}
|
||||||
|
/>
|
||||||
<FormFooter
|
<FormFooter
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
isLoading={isSubmitting}
|
isLoading={isSubmitting}
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
import { UpdateTemplateMeta } from "api/typesGenerated"
|
||||||
|
import * as Yup from "yup"
|
||||||
|
import i18next from "i18next"
|
||||||
|
|
||||||
|
export interface TemplateScheduleFormValues extends UpdateTemplateMeta {
|
||||||
|
failure_cleanup_enabled: boolean
|
||||||
|
inactivity_cleanup_enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_TTL_DAYS = 7
|
||||||
|
|
||||||
|
export const getValidationSchema = (): Yup.AnyObjectSchema =>
|
||||||
|
Yup.object({
|
||||||
|
default_ttl_ms: Yup.number()
|
||||||
|
.integer()
|
||||||
|
.min(
|
||||||
|
0,
|
||||||
|
i18next
|
||||||
|
.t("defaultTTLMinError", { ns: "templateSettingsPage" })
|
||||||
|
.toString(),
|
||||||
|
)
|
||||||
|
.max(
|
||||||
|
24 * MAX_TTL_DAYS /* 7 days in hours */,
|
||||||
|
i18next
|
||||||
|
.t("defaultTTLMaxError", { ns: "templateSettingsPage" })
|
||||||
|
.toString(),
|
||||||
|
),
|
||||||
|
max_ttl_ms: Yup.number()
|
||||||
|
.integer()
|
||||||
|
.min(
|
||||||
|
0,
|
||||||
|
i18next.t("maxTTLMinError", { ns: "templateSettingsPage" }).toString(),
|
||||||
|
)
|
||||||
|
.max(
|
||||||
|
24 * MAX_TTL_DAYS /* 7 days in hours */,
|
||||||
|
i18next.t("maxTTLMaxError", { ns: "templateSettingsPage" }).toString(),
|
||||||
|
),
|
||||||
|
failure_ttl_ms: Yup.number()
|
||||||
|
.min(0, "Failure cleanup days must not be less than 0.")
|
||||||
|
.test(
|
||||||
|
"positive-if-enabled",
|
||||||
|
"Failure cleanup days must be greater than zero when enabled.",
|
||||||
|
function (value) {
|
||||||
|
const parent = this.parent as TemplateScheduleFormValues
|
||||||
|
if (parent.failure_cleanup_enabled) {
|
||||||
|
return Boolean(value)
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
inactivity_ttl_ms: Yup.number()
|
||||||
|
.min(0, "Inactivity cleanup days must not be less than 0.")
|
||||||
|
.test(
|
||||||
|
"positive-if-enabled",
|
||||||
|
"Inactivity cleanup days must be greater than zero when enabled.",
|
||||||
|
function (value) {
|
||||||
|
const parent = this.parent as TemplateScheduleFormValues
|
||||||
|
if (parent.inactivity_cleanup_enabled) {
|
||||||
|
return Boolean(value)
|
||||||
|
} else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
allow_user_autostart: Yup.boolean(),
|
||||||
|
allow_user_autostop: Yup.boolean(),
|
||||||
|
})
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import { getWorkspaces } from "api/api"
|
||||||
|
import { compareAsc, add, endOfToday } from "date-fns"
|
||||||
|
import { WorkspaceStatus, Workspace } from "api/typesGenerated"
|
||||||
|
import { TemplateScheduleFormValues } from "./formHelpers"
|
||||||
|
|
||||||
|
const inactiveStatuses: WorkspaceStatus[] = [
|
||||||
|
"stopped",
|
||||||
|
"canceled",
|
||||||
|
"failed",
|
||||||
|
"deleted",
|
||||||
|
]
|
||||||
|
|
||||||
|
export const useWorkspacesToBeDeleted = (
|
||||||
|
formValues: TemplateScheduleFormValues,
|
||||||
|
) => {
|
||||||
|
const { data: workspacesData } = useQuery({
|
||||||
|
queryKey: ["workspaces"],
|
||||||
|
queryFn: () => getWorkspaces({}),
|
||||||
|
enabled: formValues.inactivity_cleanup_enabled,
|
||||||
|
})
|
||||||
|
return workspacesData?.workspaces?.filter((workspace: Workspace) => {
|
||||||
|
const isInactive = inactiveStatuses.includes(workspace.latest_build.status)
|
||||||
|
|
||||||
|
const proposedDeletion = add(new Date(workspace.last_used_at), {
|
||||||
|
days: formValues.inactivity_ttl_ms,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isInactive && compareAsc(proposedDeletion, endOfToday()) < 1) {
|
||||||
|
return workspace
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
+1
-1
@@ -11,7 +11,7 @@ import {
|
|||||||
renderWithTemplateSettingsLayout,
|
renderWithTemplateSettingsLayout,
|
||||||
waitForLoaderToBeRemoved,
|
waitForLoaderToBeRemoved,
|
||||||
} from "testHelpers/renderHelpers"
|
} from "testHelpers/renderHelpers"
|
||||||
import { getValidationSchema } from "./TemplateScheduleForm"
|
import { getValidationSchema } from "./TemplateScheduleForm/formHelpers"
|
||||||
import TemplateSchedulePage from "./TemplateSchedulePage"
|
import TemplateSchedulePage from "./TemplateSchedulePage"
|
||||||
import i18next from "i18next"
|
import i18next from "i18next"
|
||||||
|
|
||||||
|
|||||||
+37
-22
@@ -1,31 +1,46 @@
|
|||||||
import { action } from "@storybook/addon-actions"
|
import { action } from "@storybook/addon-actions"
|
||||||
import { Story } from "@storybook/react"
|
import { Meta, StoryObj } from "@storybook/react"
|
||||||
import { MockTemplate } from "testHelpers/entities"
|
import { MockTemplate } from "testHelpers/entities"
|
||||||
import {
|
import { TemplateSchedulePageView } from "./TemplateSchedulePageView"
|
||||||
TemplateSchedulePageView,
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||||
TemplateSchedulePageViewProps,
|
|
||||||
} from "./TemplateSchedulePageView"
|
|
||||||
|
|
||||||
export default {
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
cacheTime: 0,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
networkMode: "offlineFirst",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const meta: Meta<typeof TemplateSchedulePageView> = {
|
||||||
title: "pages/TemplateSchedulePageView",
|
title: "pages/TemplateSchedulePageView",
|
||||||
component: TemplateSchedulePageView,
|
component: TemplateSchedulePageView,
|
||||||
args: {
|
decorators: [
|
||||||
allowAdvancedScheduling: true,
|
(Story) => (
|
||||||
allowWorkspaceActions: true,
|
<QueryClientProvider client={queryClient}>
|
||||||
template: MockTemplate,
|
<Story />
|
||||||
onSubmit: action("onSubmit"),
|
</QueryClientProvider>
|
||||||
onCancel: action("cancel"),
|
),
|
||||||
},
|
],
|
||||||
|
}
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof TemplateSchedulePageView>
|
||||||
|
|
||||||
|
const defaultArgs = {
|
||||||
|
allowAdvancedScheduling: true,
|
||||||
|
allowWorkspaceActions: true,
|
||||||
|
template: MockTemplate,
|
||||||
|
onSubmit: action("onSubmit"),
|
||||||
|
onCancel: action("cancel"),
|
||||||
}
|
}
|
||||||
|
|
||||||
const Template: Story<TemplateSchedulePageViewProps> = (args) => (
|
export const Example: Story = {
|
||||||
<TemplateSchedulePageView {...args} />
|
args: { ...defaultArgs },
|
||||||
)
|
}
|
||||||
|
|
||||||
export const Example = Template.bind({})
|
export const CantSetMaxTTL: Story = {
|
||||||
Example.args = {}
|
args: { ...defaultArgs, allowAdvancedScheduling: false },
|
||||||
|
|
||||||
export const CantSetMaxTTL = Template.bind({})
|
|
||||||
CantSetMaxTTL.args = {
|
|
||||||
allowAdvancedScheduling: false,
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { Template, UpdateTemplateMeta } from "api/typesGenerated"
|
import { Template, UpdateTemplateMeta } from "api/typesGenerated"
|
||||||
import { ComponentProps, FC } from "react"
|
import { ComponentProps, FC } from "react"
|
||||||
import { TemplateScheduleForm } from "./TemplateScheduleForm"
|
import { TemplateScheduleForm } from "./TemplateScheduleForm/TemplateScheduleForm"
|
||||||
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"
|
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader"
|
||||||
import { makeStyles } from "@mui/styles"
|
import { makeStyles } from "@mui/styles"
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
import { screen } from "@testing-library/react"
|
import { screen } from "@testing-library/react"
|
||||||
import { rest } from "msw"
|
import { rest } from "msw"
|
||||||
import * as CreateDayString from "utils/createDayString"
|
import * as CreateDayString from "utils/createDayString"
|
||||||
import {
|
import { MockWorkspace, MockWorkspacesResponse } from "testHelpers/entities"
|
||||||
MockWorkspace,
|
|
||||||
MockWorkspacesResponse,
|
|
||||||
MockEntitlementsWithScheduling,
|
|
||||||
MockWorkspacesResponseWithDeletions,
|
|
||||||
} from "testHelpers/entities"
|
|
||||||
import { history, renderWithAuth } from "testHelpers/renderHelpers"
|
import { history, renderWithAuth } from "testHelpers/renderHelpers"
|
||||||
import { server } from "testHelpers/server"
|
import { server } from "testHelpers/server"
|
||||||
import WorkspacesPage from "./WorkspacesPage"
|
import WorkspacesPage from "./WorkspacesPage"
|
||||||
import { i18n } from "i18n"
|
import { i18n } from "i18n"
|
||||||
import * as API from "api/api"
|
|
||||||
import userEvent from "@testing-library/user-event"
|
|
||||||
|
|
||||||
const { t } = i18n
|
const { t } = i18n
|
||||||
|
|
||||||
@@ -48,24 +41,4 @@ describe("WorkspacesPage", () => {
|
|||||||
)
|
)
|
||||||
expect(templateDisplayNames).toHaveLength(MockWorkspacesResponse.count)
|
expect(templateDisplayNames).toHaveLength(MockWorkspacesResponse.count)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("displays banner for impending deletions", async () => {
|
|
||||||
jest
|
|
||||||
.spyOn(API, "getEntitlements")
|
|
||||||
.mockResolvedValue(MockEntitlementsWithScheduling)
|
|
||||||
|
|
||||||
jest
|
|
||||||
.spyOn(API, "getWorkspaces")
|
|
||||||
.mockResolvedValue(MockWorkspacesResponseWithDeletions)
|
|
||||||
|
|
||||||
renderWithAuth(<WorkspacesPage />)
|
|
||||||
|
|
||||||
const banner = await screen.findByText(
|
|
||||||
"You have workspaces that will be deleted soon due to inactivity. To keep these workspaces, connect to them via SSH or the web terminal.",
|
|
||||||
)
|
|
||||||
const user = userEvent.setup()
|
|
||||||
await user.click(screen.getByTestId("dismiss-banner-btn"))
|
|
||||||
|
|
||||||
expect(banner).toBeEmptyDOMElement
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user