Merge remote-tracking branch 'origin/main' into lowercase-workspace-name/kira-pilot

This commit is contained in:
Kira Pilot 2023-11-05 01:00:42 +00:00
commit fff170bb77
111 changed files with 2709 additions and 2344 deletions

58
cli/autoupdate.go Normal file
View File

@ -0,0 +1,58 @@
package cli
import (
"fmt"
"strings"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clibase"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
)
func (r *RootCmd) autoupdate() *clibase.Cmd {
client := new(codersdk.Client)
cmd := &clibase.Cmd{
Annotations: workspaceCommand,
Use: "autoupdate <workspace> <always|never>",
Short: "Toggle auto-update policy for a workspace",
Middleware: clibase.Chain(
clibase.RequireNArgs(2),
r.InitClient(client),
),
Handler: func(inv *clibase.Invocation) error {
policy := strings.ToLower(inv.Args[1])
err := validateAutoUpdatePolicy(policy)
if err != nil {
return xerrors.Errorf("validate policy: %w", err)
}
workspace, err := namedWorkspace(inv.Context(), client, inv.Args[0])
if err != nil {
return xerrors.Errorf("get workspace: %w", err)
}
err = client.UpdateWorkspaceAutomaticUpdates(inv.Context(), workspace.ID, codersdk.UpdateWorkspaceAutomaticUpdatesRequest{
AutomaticUpdates: codersdk.AutomaticUpdates(policy),
})
if err != nil {
return xerrors.Errorf("update workspace automatic updates policy: %w", err)
}
_, _ = fmt.Fprintf(inv.Stdout, "Updated workspace %q auto-update policy to %q\n", workspace.Name, policy)
return nil
},
}
cmd.Options = append(cmd.Options, cliui.SkipPromptOption())
return cmd
}
func validateAutoUpdatePolicy(arg string) error {
switch codersdk.AutomaticUpdates(arg) {
case codersdk.AutomaticUpdatesAlways, codersdk.AutomaticUpdatesNever:
return nil
default:
return xerrors.Errorf("invalid option %q must be either of %q or %q", arg, codersdk.AutomaticUpdatesAlways, codersdk.AutomaticUpdatesNever)
}
}

79
cli/autoupdate_test.go Normal file
View File

@ -0,0 +1,79 @@
package cli_test
import (
"bytes"
"fmt"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
)
func TestAutoUpdate(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
require.Equal(t, codersdk.AutomaticUpdatesNever, workspace.AutomaticUpdates)
expectedPolicy := codersdk.AutomaticUpdatesAlways
inv, root := clitest.New(t, "autoupdate", workspace.Name, string(expectedPolicy))
clitest.SetupConfig(t, member, root)
var buf bytes.Buffer
inv.Stdout = &buf
err := inv.Run()
require.NoError(t, err)
require.Contains(t, buf.String(), fmt.Sprintf("Updated workspace %q auto-update policy to %q", workspace.Name, expectedPolicy))
workspace = coderdtest.MustWorkspace(t, client, workspace.ID)
require.Equal(t, expectedPolicy, workspace.AutomaticUpdates)
})
t.Run("InvalidArgs", func(t *testing.T) {
type testcase struct {
Name string
Args []string
ErrorContains string
}
cases := []testcase{
{
Name: "NoPolicy",
Args: []string{"autoupdate", "ws"},
ErrorContains: "wanted 2 args but got 1",
},
{
Name: "InvalidPolicy",
Args: []string{"autoupdate", "ws", "sometimes"},
ErrorContains: `invalid option "sometimes" must be either of`,
},
}
for _, c := range cases {
c := c
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
inv, root := clitest.New(t, c.Args...)
clitest.SetupConfig(t, client, root)
err := inv.Run()
require.Error(t, err)
require.Contains(t, err.Error(), c.ErrorContains)
})
}
})
}

View File

@ -22,8 +22,9 @@ import (
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/provisionersdk/proto"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
@ -64,8 +65,7 @@ func TestConfigSSH(t *testing.T) {
const hostname = "test-coder."
const expectedKey = "ConnectionAttempts"
const removeKey = "ConnectionTimeout"
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
ConfigSSH: codersdk.SSHConfigResponse{
HostnamePrefix: hostname,
SSHConfigOptions: map[string]string{
@ -76,32 +76,13 @@ func TestConfigSSH(t *testing.T) {
},
})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: []*proto.Response{{
Type: &proto.Response_Plan{
Plan: &proto.PlanComplete{
Resources: []*proto.Resource{{
Name: "example",
Type: "aws_instance",
Agents: []*proto.Agent{{
Id: uuid.NewString(),
Name: "example",
}},
}},
},
},
}},
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
ws, authToken := dbfake.WorkspaceWithAgent(t, db, database.Workspace{
OrganizationID: owner.OrganizationID,
OwnerID: memberUser.ID,
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
_ = agenttest.New(t, client.URL, authToken)
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
resources := coderdtest.AwaitWorkspaceAgents(t, client, ws.ID)
agentConn, err := client.DialWorkspaceAgent(context.Background(), resources[0].Agents[0].ID, nil)
require.NoError(t, err)
defer agentConn.Close()
@ -172,7 +153,7 @@ func TestConfigSSH(t *testing.T) {
home := filepath.Dir(filepath.Dir(sshConfigFile))
// #nosec
sshCmd := exec.Command("ssh", "-F", sshConfigFile, hostname+workspace.Name, "echo", "test")
sshCmd := exec.Command("ssh", "-F", sshConfigFile, hostname+ws.Name, "echo", "test")
pty = ptytest.New(t)
// Set HOME because coder config is included from ~/.ssh/coder.
sshCmd.Env = append(sshCmd.Env, fmt.Sprintf("HOME=%s", home))
@ -213,13 +194,13 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
match, write string
}
tests := []struct {
name string
args []string
matches []match
writeConfig writeConfig
wantConfig wantConfig
wantErr bool
echoResponse *echo.Responses
name string
args []string
matches []match
writeConfig writeConfig
wantConfig wantConfig
wantErr bool
hasAgent bool
}{
{
name: "Config file is created",
@ -576,11 +557,8 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
args: []string{
"-y", "--coder-binary-path", "/foo/bar/coder",
},
wantErr: false,
echoResponse: &echo.Responses{
Parse: echo.ParseComplete,
ProvisionApply: echo.ProvisionApplyWithAgent(""),
},
wantErr: false,
hasAgent: true,
wantConfig: wantConfig{
regexMatch: "ProxyCommand /foo/bar/coder",
},
@ -591,15 +569,14 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var (
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
user = coderdtest.CreateFirstUser(t, client)
version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, tt.echoResponse)
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID)
_ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
)
client, db := coderdtest.NewWithDatabase(t, nil)
user := coderdtest.CreateFirstUser(t, client)
if tt.hasAgent {
_, _ = dbfake.WorkspaceWithAgent(t, db, database.Workspace{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
})
}
// Prepare ssh config files.
sshConfigName := sshConfigFileName(t)
@ -613,6 +590,7 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
}
args = append(args, tt.args...)
inv, root := clitest.New(t, args...)
//nolint:gocritic // This has always ran with the admin user.
clitest.SetupConfig(t, client, root)
pty := ptytest.New(t)
@ -710,17 +688,15 @@ func TestConfigSSH_Hostnames(t *testing.T) {
resources = append(resources, resource)
}
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
client, db := coderdtest.NewWithDatabase(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
// authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID,
echo.WithResources(resources))
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
ws := dbfake.Workspace(t, db, database.Workspace{
OrganizationID: owner.OrganizationID,
OwnerID: memberUser.ID,
})
dbfake.WorkspaceBuild(t, db, ws, database.WorkspaceBuild{}, resources...)
sshConfigFile := sshConfigFileName(t)
inv, root := clitest.New(t, "config-ssh", "--ssh-config-file", sshConfigFile)
@ -745,7 +721,7 @@ func TestConfigSSH_Hostnames(t *testing.T) {
var expectedHosts []string
for _, hostnamePattern := range tt.expected {
hostname := strings.ReplaceAll(hostnamePattern, "@", workspace.Name)
hostname := strings.ReplaceAll(hostnamePattern, "@", ws.Name)
expectedHosts = append(expectedHosts, hostname)
}

View File

@ -140,9 +140,9 @@ func (r *RootCmd) create() *clibase.Cmd {
}
richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{
Action: WorkspaceCreate,
Template: template,
NewWorkspaceName: workspaceName,
Action: WorkspaceCreate,
TemplateVersionID: template.ActiveVersionID,
NewWorkspaceName: workspaceName,
RichParameterFile: parameterFlags.richParameterFile,
RichParameters: cliRichParameters,
@ -224,10 +224,9 @@ func (r *RootCmd) create() *clibase.Cmd {
}
type prepWorkspaceBuildArgs struct {
Action WorkspaceCLIAction
Template codersdk.Template
NewWorkspaceName string
WorkspaceID uuid.UUID
Action WorkspaceCLIAction
TemplateVersionID uuid.UUID
NewWorkspaceName string
LastBuildParameters []codersdk.WorkspaceBuildParameter
@ -244,7 +243,7 @@ type prepWorkspaceBuildArgs struct {
func prepWorkspaceBuild(inv *clibase.Invocation, client *codersdk.Client, args prepWorkspaceBuildArgs) ([]codersdk.WorkspaceBuildParameter, error) {
ctx := inv.Context()
templateVersion, err := client.TemplateVersion(ctx, args.Template.ActiveVersionID)
templateVersion, err := client.TemplateVersion(ctx, args.TemplateVersionID)
if err != nil {
return nil, xerrors.Errorf("get template version: %w", err)
}

View File

@ -600,9 +600,9 @@ func (r *RootCmd) scaletestCreateWorkspaces() *clibase.Cmd {
}
richParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{
Action: WorkspaceCreate,
Template: tpl,
NewWorkspaceName: "scaletest-N", // TODO: the scaletest runner will pass in a different name here. Does this matter?
Action: WorkspaceCreate,
TemplateVersionID: tpl.ActiveVersionID,
NewWorkspaceName: "scaletest-N", // TODO: the scaletest runner will pass in a different name here. Does this matter?
RichParameterFile: parameterFlags.richParameterFile,
RichParameters: cliRichParameters,

View File

@ -16,7 +16,6 @@ import (
"testing"
"github.com/gliderlabs/ssh"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
gossh "golang.org/x/crypto/ssh"
@ -24,9 +23,10 @@ import (
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
)
@ -34,7 +34,7 @@ import (
func prepareTestGitSSH(ctx context.Context, t *testing.T) (*agentsdk.Client, string, gossh.PublicKey) {
t.Helper()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
client, db := coderdtest.NewWithDatabase(t, nil)
user := coderdtest.CreateFirstUser(t, client)
ctx, cancel := context.WithCancel(ctx)
@ -48,24 +48,17 @@ func prepareTestGitSSH(ctx context.Context, t *testing.T) (*agentsdk.Client, str
require.NoError(t, err)
// setup template
agentToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.PlanComplete,
ProvisionApply: echo.ProvisionApplyWithAgent(agentToken),
ws, agentToken := dbfake.WorkspaceWithAgent(t, db, database.Workspace{
OrganizationID: user.OrganizationID,
OwnerID: user.UserID,
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
// start workspace agent
agentClient := agentsdk.New(client.URL)
agentClient.SetSessionToken(agentToken)
_ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) {
o.Client = agentClient
})
_ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
_ = coderdtest.AwaitWorkspaceAgents(t, client, ws.ID)
return agentClient, agentToken, pubkey
}

View File

@ -11,6 +11,8 @@ import (
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
@ -20,14 +22,13 @@ func TestList(t *testing.T) {
t.Parallel()
t.Run("Single", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
client, db := coderdtest.NewWithDatabase(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
ws, _ := dbfake.WorkspaceWithAgent(t, db, database.Workspace{
OrganizationID: owner.OrganizationID,
OwnerID: memberUser.ID,
})
inv, root := clitest.New(t, "ls")
clitest.SetupConfig(t, member, root)
pty := ptytest.New(t).Attach(inv)
@ -40,7 +41,7 @@ func TestList(t *testing.T) {
assert.NoError(t, errC)
close(done)
}()
pty.ExpectMatch(workspace.Name)
pty.ExpectMatch(ws.Name)
pty.ExpectMatch("Started")
cancelFunc()
<-done
@ -48,14 +49,13 @@ func TestList(t *testing.T) {
t.Run("JSON", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
client, db := coderdtest.NewWithDatabase(t, nil)
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
dbfake.WorkspaceWithAgent(t, db, database.Workspace{
OrganizationID: owner.OrganizationID,
OwnerID: memberUser.ID,
})
inv, root := clitest.New(t, "list", "--output=json")
clitest.SetupConfig(t, member, root)
@ -68,8 +68,8 @@ func TestList(t *testing.T) {
err := inv.WithContext(ctx).Run()
require.NoError(t, err)
var templates []codersdk.Workspace
require.NoError(t, json.Unmarshal(out.Bytes(), &templates))
require.Len(t, templates, 1)
var workspaces []codersdk.Workspace
require.NoError(t, json.Unmarshal(out.Bytes(), &workspaces))
require.Len(t, workspaces, 1)
})
}

View File

@ -20,6 +20,13 @@ type workspaceParameterFlags struct {
richParameterFile string
richParameters []string
promptRichParameters bool
}
func (wpf *workspaceParameterFlags) allOptions() []clibase.Option {
options := append(wpf.cliBuildOptions(), wpf.cliParameters()...)
return append(options, wpf.alwaysPrompt())
}
func (wpf *workspaceParameterFlags) cliBuildOptions() []clibase.Option {
@ -55,6 +62,14 @@ func (wpf *workspaceParameterFlags) cliParameters() []clibase.Option {
}
}
func (wpf *workspaceParameterFlags) alwaysPrompt() clibase.Option {
return clibase.Option{
Flag: "always-prompt",
Description: "Always prompt all parameters. Does not pull parameter values from existing workspace.",
Value: clibase.BoolOf(&wpf.promptRichParameters),
}
}
func asWorkspaceBuildParameters(nameValuePairs []string) ([]codersdk.WorkspaceBuildParameter, error) {
var params []codersdk.WorkspaceBuildParameter
for _, nameValue := range nameValuePairs {

View File

@ -194,7 +194,7 @@ func (pr *ParameterResolver) resolveWithInput(resolved []codersdk.WorkspaceBuild
(action == WorkspaceUpdate && promptParameterOption) ||
(action == WorkspaceUpdate && tvp.Mutable && tvp.Required) ||
(action == WorkspaceUpdate && !tvp.Mutable && firstTimeUse) ||
(action == WorkspaceUpdate && tvp.Mutable && !tvp.Ephemeral && pr.promptRichParameters) {
(tvp.Mutable && !tvp.Ephemeral && pr.promptRichParameters) {
parameterValue, err := cliui.RichParameter(inv, tvp)
if err != nil {
return nil, err

View File

@ -18,8 +18,9 @@ import (
"github.com/coder/coder/v2/agent/agenttest"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/provisioner/echo"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
)
@ -132,10 +133,10 @@ func TestPortForward(t *testing.T) {
// Setup agent once to be shared between test-cases (avoid expensive
// non-parallel setup).
var (
client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
admin = coderdtest.CreateFirstUser(t, client)
member, _ = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
workspace = runAgent(t, client, member)
client, db = coderdtest.NewWithDatabase(t, nil)
admin = coderdtest.CreateFirstUser(t, client)
member, memberUser = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
workspace = runAgent(t, client, memberUser.ID, db)
)
for _, c := range cases {
@ -299,35 +300,22 @@ func TestPortForward(t *testing.T) {
// runAgent creates a fake workspace and starts an agent locally for that
// workspace. The agent will be cleaned up on test completion.
// nolint:unused
func runAgent(t *testing.T, adminClient, userClient *codersdk.Client) codersdk.Workspace {
ctx := context.Background()
user, err := userClient.User(ctx, codersdk.Me)
func runAgent(t *testing.T, client *codersdk.Client, owner uuid.UUID, db database.Store) database.Workspace {
user, err := client.User(context.Background(), codersdk.Me)
require.NoError(t, err, "specified user does not exist")
require.Greater(t, len(user.OrganizationIDs), 0, "user has no organizations")
orgID := user.OrganizationIDs[0]
// Setup template
agentToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, adminClient, orgID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.PlanComplete,
ProvisionApply: echo.ProvisionApplyWithAgent(agentToken),
ws, agentToken := dbfake.WorkspaceWithAgent(t, db, database.Workspace{
OrganizationID: orgID,
OwnerID: owner,
})
// Create template and workspace
template := coderdtest.CreateTemplate(t, adminClient, orgID, version.ID)
coderdtest.AwaitTemplateVersionJobCompleted(t, adminClient, version.ID)
workspace := coderdtest.CreateWorkspace(t, userClient, orgID, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, adminClient, workspace.LatestBuild.ID)
_ = agenttest.New(t, adminClient.URL, agentToken,
_ = agenttest.New(t, client.URL, agentToken,
func(o *agent.Options) {
o.SSHMaxTimeout = 60 * time.Second
},
)
coderdtest.AwaitWorkspaceAgents(t, adminClient, workspace.ID)
return workspace
coderdtest.AwaitWorkspaceAgents(t, client, ws.ID)
return ws
}
// setupTestListener starts accepting connections and echoing a single packet.

View File

@ -25,7 +25,7 @@ func (r *RootCmd) restart() *clibase.Cmd {
clibase.RequireNArgs(1),
r.InitClient(client),
),
Options: append(parameterFlags.cliBuildOptions(), cliui.SkipPromptOption()),
Options: clibase.OptionSet{cliui.SkipPromptOption()},
Handler: func(inv *clibase.Invocation) error {
ctx := inv.Context()
out := inv.Stdout
@ -35,25 +35,7 @@ func (r *RootCmd) restart() *clibase.Cmd {
return err
}
lastBuildParameters, err := client.WorkspaceBuildParameters(inv.Context(), workspace.LatestBuild.ID)
if err != nil {
return err
}
buildOptions, err := asWorkspaceBuildParameters(parameterFlags.buildOptions)
if err != nil {
return xerrors.Errorf("can't parse build options: %w", err)
}
buildParameters, err := prepStartWorkspace(inv, client, prepStartWorkspaceArgs{
Action: WorkspaceRestart,
TemplateVersionID: workspace.LatestBuild.TemplateVersionID,
LastBuildParameters: lastBuildParameters,
PromptBuildOptions: parameterFlags.promptBuildOptions,
BuildOptions: buildOptions,
})
startReq, err := buildWorkspaceStartRequest(inv, client, workspace, parameterFlags, WorkspaceRestart)
if err != nil {
return err
}
@ -72,27 +54,18 @@ func (r *RootCmd) restart() *clibase.Cmd {
if err != nil {
return err
}
err = cliui.WorkspaceBuild(ctx, out, client, build.ID)
if err != nil {
return err
}
req := codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionStart,
RichParameterValues: buildParameters,
TemplateVersionID: workspace.LatestBuild.TemplateVersionID,
}
build, err = client.CreateWorkspaceBuild(ctx, workspace.ID, req)
build, err = client.CreateWorkspaceBuild(ctx, workspace.ID, startReq)
// It's possible for a workspace build to fail due to the template requiring starting
// workspaces with the active version.
if cerr, ok := codersdk.AsError(err); ok && cerr.StatusCode() == http.StatusUnauthorized {
build, err = startWorkspaceActiveVersion(inv, client, startWorkspaceActiveVersionArgs{
BuildOptions: buildOptions,
LastBuildParameters: lastBuildParameters,
PromptBuildOptions: parameterFlags.promptBuildOptions,
Workspace: workspace,
})
_, _ = fmt.Fprintln(inv.Stdout, "Failed to restart with the template version from your last build. Policy may require you to restart with the current active template version.")
build, err = startWorkspace(inv, client, workspace, parameterFlags, WorkspaceUpdate)
if err != nil {
return xerrors.Errorf("start workspace with active template version: %w", err)
}
@ -112,5 +85,8 @@ func (r *RootCmd) restart() *clibase.Cmd {
return nil
},
}
cmd.Options = append(cmd.Options, parameterFlags.allOptions()...)
return cmd
}

View File

@ -239,4 +239,55 @@ func TestRestartWithParameters(t *testing.T) {
Value: immutableParameterValue,
})
})
t.Run("AlwaysPrompt", func(t *testing.T) {
t.Parallel()
// Create the workspace
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, mutableParamsResponse)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.RichParameterValues = []codersdk.WorkspaceBuildParameter{
{
Name: mutableParameterName,
Value: mutableParameterValue,
},
}
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
inv, root := clitest.New(t, "restart", workspace.Name, "-y", "--always-prompt")
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
// We should be prompted for the parameters again.
newValue := "xyz"
pty.ExpectMatch(mutableParameterName)
pty.WriteLine(newValue)
pty.ExpectMatch("workspace has been restarted")
<-doneChan
// Verify that the updated values are persisted.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
workspace, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{})
require.NoError(t, err)
actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID)
require.NoError(t, err)
require.Contains(t, actualParameters, codersdk.WorkspaceBuildParameter{
Name: mutableParameterName,
Value: newValue,
})
})
}

View File

@ -97,6 +97,7 @@ func (r *RootCmd) Core() []*clibase.Cmd {
r.version(defaultVersionInfo),
// Workspace Commands
r.autoupdate(),
r.configSSH(),
r.create(),
r.deleteWorkspace(),

View File

@ -136,9 +136,9 @@ func TestDERPHeaders(t *testing.T) {
})
var (
admin = coderdtest.CreateFirstUser(t, client)
member, _ = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
workspace = runAgent(t, client, member)
admin = coderdtest.CreateFirstUser(t, client)
member, memberUser = coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
workspace = runAgent(t, client, memberUser.ID, newOptions.Database)
)
// Inject custom /derp handler so we can inspect the headers.

View File

@ -5,7 +5,6 @@ import (
"net/http"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/cli/clibase"
@ -25,52 +24,19 @@ func (r *RootCmd) start() *clibase.Cmd {
clibase.RequireNArgs(1),
r.InitClient(client),
),
Options: append(parameterFlags.cliBuildOptions(), cliui.SkipPromptOption()),
Options: clibase.OptionSet{cliui.SkipPromptOption()},
Handler: func(inv *clibase.Invocation) error {
workspace, err := namedWorkspace(inv.Context(), client, inv.Args[0])
if err != nil {
return err
}
lastBuildParameters, err := client.WorkspaceBuildParameters(inv.Context(), workspace.LatestBuild.ID)
if err != nil {
return err
}
buildOptions, err := asWorkspaceBuildParameters(parameterFlags.buildOptions)
if err != nil {
return xerrors.Errorf("unable to parse build options: %w", err)
}
buildParameters, err := prepStartWorkspace(inv, client, prepStartWorkspaceArgs{
Action: WorkspaceStart,
TemplateVersionID: workspace.LatestBuild.TemplateVersionID,
LastBuildParameters: lastBuildParameters,
PromptBuildOptions: parameterFlags.promptBuildOptions,
BuildOptions: buildOptions,
})
if err != nil {
return err
}
req := codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionStart,
RichParameterValues: buildParameters,
TemplateVersionID: workspace.LatestBuild.TemplateVersionID,
}
build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, req)
build, err := startWorkspace(inv, client, workspace, parameterFlags, WorkspaceStart)
// It's possible for a workspace build to fail due to the template requiring starting
// workspaces with the active version.
if cerr, ok := codersdk.AsError(err); ok && cerr.StatusCode() == http.StatusUnauthorized {
build, err = startWorkspaceActiveVersion(inv, client, startWorkspaceActiveVersionArgs{
BuildOptions: buildOptions,
LastBuildParameters: lastBuildParameters,
PromptBuildOptions: parameterFlags.promptBuildOptions,
Workspace: workspace,
})
_, _ = fmt.Fprintln(inv.Stdout, "Failed to restart with the template version from your last build. Policy may require you to restart with the current active template version.")
build, err = startWorkspace(inv, client, workspace, parameterFlags, WorkspaceUpdate)
if err != nil {
return xerrors.Errorf("start workspace with active template version: %w", err)
}
@ -90,75 +56,69 @@ func (r *RootCmd) start() *clibase.Cmd {
return nil
},
}
cmd.Options = append(cmd.Options, parameterFlags.allOptions()...)
return cmd
}
type prepStartWorkspaceArgs struct {
Action WorkspaceCLIAction
TemplateVersionID uuid.UUID
LastBuildParameters []codersdk.WorkspaceBuildParameter
PromptBuildOptions bool
BuildOptions []codersdk.WorkspaceBuildParameter
}
func prepStartWorkspace(inv *clibase.Invocation, client *codersdk.Client, args prepStartWorkspaceArgs) ([]codersdk.WorkspaceBuildParameter, error) {
ctx := inv.Context()
templateVersion, err := client.TemplateVersion(ctx, args.TemplateVersionID)
if err != nil {
return nil, xerrors.Errorf("get template version: %w", err)
func buildWorkspaceStartRequest(inv *clibase.Invocation, client *codersdk.Client, workspace codersdk.Workspace, parameterFlags workspaceParameterFlags, action WorkspaceCLIAction) (codersdk.CreateWorkspaceBuildRequest, error) {
version := workspace.LatestBuild.TemplateVersionID
if workspace.AutomaticUpdates == codersdk.AutomaticUpdatesAlways || action == WorkspaceUpdate {
version = workspace.TemplateActiveVersionID
if version != workspace.LatestBuild.TemplateVersionID {
action = WorkspaceUpdate
}
}
templateVersionParameters, err := client.TemplateVersionRichParameters(inv.Context(), templateVersion.ID)
lastBuildParameters, err := client.WorkspaceBuildParameters(inv.Context(), workspace.LatestBuild.ID)
if err != nil {
return nil, xerrors.Errorf("get template version rich parameters: %w", err)
return codersdk.CreateWorkspaceBuildRequest{}, err
}
resolver := new(ParameterResolver).
WithLastBuildParameters(args.LastBuildParameters).
WithPromptBuildOptions(args.PromptBuildOptions).
WithBuildOptions(args.BuildOptions)
return resolver.Resolve(inv, args.Action, templateVersionParameters)
}
type startWorkspaceActiveVersionArgs struct {
BuildOptions []codersdk.WorkspaceBuildParameter
LastBuildParameters []codersdk.WorkspaceBuildParameter
PromptBuildOptions bool
Workspace codersdk.Workspace
}
func startWorkspaceActiveVersion(inv *clibase.Invocation, client *codersdk.Client, args startWorkspaceActiveVersionArgs) (codersdk.WorkspaceBuild, error) {
_, _ = fmt.Fprintln(inv.Stdout, "Failed to restart with the template version from your last build. Policy may require you to restart with the current active template version.")
template, err := client.Template(inv.Context(), args.Workspace.TemplateID)
buildOptions, err := asWorkspaceBuildParameters(parameterFlags.buildOptions)
if err != nil {
return codersdk.WorkspaceBuild{}, xerrors.Errorf("get template: %w", err)
return codersdk.CreateWorkspaceBuildRequest{}, xerrors.Errorf("unable to parse build options: %w", err)
}
buildParameters, err := prepStartWorkspace(inv, client, prepStartWorkspaceArgs{
Action: WorkspaceStart,
TemplateVersionID: template.ActiveVersionID,
cliRichParameters, err := asWorkspaceBuildParameters(parameterFlags.richParameters)
if err != nil {
return codersdk.CreateWorkspaceBuildRequest{}, xerrors.Errorf("unable to parse build options: %w", err)
}
LastBuildParameters: args.LastBuildParameters,
buildParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{
Action: action,
TemplateVersionID: version,
NewWorkspaceName: workspace.Name,
LastBuildParameters: lastBuildParameters,
PromptBuildOptions: args.PromptBuildOptions,
BuildOptions: args.BuildOptions,
PromptBuildOptions: parameterFlags.promptBuildOptions,
BuildOptions: buildOptions,
PromptRichParameters: parameterFlags.promptRichParameters,
RichParameters: cliRichParameters,
RichParameterFile: parameterFlags.richParameterFile,
})
if err != nil {
return codersdk.WorkspaceBuild{}, err
return codersdk.CreateWorkspaceBuildRequest{}, err
}
build, err := client.CreateWorkspaceBuild(inv.Context(), args.Workspace.ID, codersdk.CreateWorkspaceBuildRequest{
return codersdk.CreateWorkspaceBuildRequest{
Transition: codersdk.WorkspaceTransitionStart,
RichParameterValues: buildParameters,
TemplateVersionID: template.ActiveVersionID,
})
TemplateVersionID: version,
}, nil
}
func startWorkspace(inv *clibase.Invocation, client *codersdk.Client, workspace codersdk.Workspace, parameterFlags workspaceParameterFlags, action WorkspaceCLIAction) (codersdk.WorkspaceBuild, error) {
req, err := buildWorkspaceStartRequest(inv, client, workspace, parameterFlags, action)
if err != nil {
return codersdk.WorkspaceBuild{}, err
}
build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, req)
if err != nil {
return codersdk.WorkspaceBuild{}, xerrors.Errorf("create workspace build: %w", err)
}
return build, nil
}

View File

@ -26,6 +26,52 @@ const (
immutableParameterName = "immutable_parameter"
immutableParameterDescription = "This is immutable parameter"
immutableParameterValue = "abc"
mutableParameterName = "mutable_parameter"
mutableParameterValue = "hello"
)
var (
mutableParamsResponse = &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: []*proto.Response{
{
Type: &proto.Response_Plan{
Plan: &proto.PlanComplete{
Parameters: []*proto.RichParameter{
{
Name: mutableParameterName,
Description: "This is a mutable parameter",
Required: true,
Mutable: true,
},
},
},
},
},
},
ProvisionApply: echo.ApplyComplete,
}
immutableParamsResponse = &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: []*proto.Response{
{
Type: &proto.Response_Plan{
Plan: &proto.PlanComplete{
Parameters: []*proto.RichParameter{
{
Name: immutableParameterName,
Description: immutableParameterDescription,
Required: true,
},
},
},
},
},
},
ProvisionApply: echo.ApplyComplete,
}
)
func TestStart(t *testing.T) {
@ -147,26 +193,6 @@ func TestStart(t *testing.T) {
func TestStartWithParameters(t *testing.T) {
t.Parallel()
echoResponses := &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: []*proto.Response{
{
Type: &proto.Response_Plan{
Plan: &proto.PlanComplete{
Parameters: []*proto.RichParameter{
{
Name: immutableParameterName,
Description: immutableParameterDescription,
Required: true,
},
},
},
},
},
},
ProvisionApply: echo.ApplyComplete,
}
t.Run("DoNotAskForImmutables", func(t *testing.T) {
t.Parallel()
@ -174,7 +200,7 @@ func TestStartWithParameters(t *testing.T) {
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, echoResponses)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, immutableParamsResponse)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
@ -218,4 +244,133 @@ func TestStartWithParameters(t *testing.T) {
Value: immutableParameterValue,
})
})
t.Run("AlwaysPrompt", func(t *testing.T) {
t.Parallel()
// Create the workspace
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, mutableParamsResponse)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.RichParameterValues = []codersdk.WorkspaceBuildParameter{
{
Name: mutableParameterName,
Value: mutableParameterValue,
},
}
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
// Stop the workspace
workspaceBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspaceBuild.ID)
// Start the workspace again
inv, root := clitest.New(t, "start", workspace.Name, "--always-prompt")
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
newValue := "xyz"
pty.ExpectMatch(mutableParameterName)
pty.WriteLine(newValue)
pty.ExpectMatch("workspace has been started")
<-doneChan
// Verify that the updated values are persisted.
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
workspace, err := client.WorkspaceByOwnerAndName(ctx, workspace.OwnerName, workspace.Name, codersdk.WorkspaceOptions{})
require.NoError(t, err)
actualParameters, err := client.WorkspaceBuildParameters(ctx, workspace.LatestBuild.ID)
require.NoError(t, err)
require.Contains(t, actualParameters, codersdk.WorkspaceBuildParameter{
Name: mutableParameterName,
Value: newValue,
})
})
}
// TestStartAutoUpdate also tests restart since the flows are virtually identical.
func TestStartAutoUpdate(t *testing.T) {
t.Parallel()
const (
stringParameterName = "myparam"
stringParameterValue = "abc"
)
stringRichParameters := []*proto.RichParameter{
{Name: stringParameterName, Type: "string", Mutable: true, Required: true},
}
type testcase struct {
Name string
Cmd string
}
cases := []testcase{
{
Name: "StartOK",
Cmd: "start",
},
{
Name: "RestartOK",
Cmd: "restart",
},
}
for _, c := range cases {
c := c
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
member, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
version1 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version1.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version1.ID)
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.AutomaticUpdates = codersdk.AutomaticUpdatesAlways
})
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
if c.Cmd == "start" {
coderdtest.MustTransitionWorkspace(t, member, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
}
version2 := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, prepareEchoResponses(stringRichParameters), func(ctvr *codersdk.CreateTemplateVersionRequest) {
ctvr.TemplateID = template.ID
})
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version2.ID)
coderdtest.UpdateActiveTemplateVersion(t, client, template.ID, version2.ID)
inv, root := clitest.New(t, c.Cmd, "-y", workspace.Name)
clitest.SetupConfig(t, member, root)
doneChan := make(chan struct{})
pty := ptytest.New(t).Attach(inv)
go func() {
defer close(doneChan)
err := inv.Run()
assert.NoError(t, err)
}()
pty.ExpectMatch(stringParameterName)
pty.WriteLine(stringParameterValue)
<-doneChan
workspace = coderdtest.MustWorkspace(t, member, workspace.ID)
require.Equal(t, version2.ID, workspace.LatestBuild.TemplateVersionID)
})
}
}

View File

@ -14,6 +14,7 @@ USAGE:
$ coder templates init
SUBCOMMANDS:
autoupdate Toggle auto-update policy for a workspace
config-ssh Add an SSH Host entry for your workspaces "ssh
coder.workspace"
create Create a workspace

View File

@ -0,0 +1,13 @@
coder v0.0.0-devel
USAGE:
coder autoupdate [flags] <workspace> <always|never>
Toggle auto-update policy for a workspace
OPTIONS:
-y, --yes bool
Bypass prompts.
———
Run `coder --help` for a list of global options.

View File

@ -6,12 +6,23 @@ USAGE:
Restart a workspace
OPTIONS:
--always-prompt bool
Always prompt all parameters. Does not pull parameter values from
existing workspace.
--build-option string-array, $CODER_BUILD_OPTION
Build option value in the format "name=value".
--build-options bool
Prompt for one-time build options defined with ephemeral parameters.
--parameter string-array, $CODER_RICH_PARAMETER
Rich parameter value in the format "name=value".
--rich-parameter-file string, $CODER_RICH_PARAMETER_FILE
Specify a file path with values for rich parameters defined in the
template.
-y, --yes bool
Bypass prompts.

View File

@ -6,12 +6,23 @@ USAGE:
Start a workspace
OPTIONS:
--always-prompt bool
Always prompt all parameters. Does not pull parameter values from
existing workspace.
--build-option string-array, $CODER_BUILD_OPTION
Build option value in the format "name=value".
--build-options bool
Prompt for one-time build options defined with ephemeral parameters.
--parameter string-array, $CODER_RICH_PARAMETER
Rich parameter value in the format "name=value".
--rich-parameter-file string, $CODER_RICH_PARAMETER_FILE
Specify a file path with values for rich parameters defined in the
template.
-y, --yes bool
Bypass prompts.

View File

@ -10,11 +10,7 @@ import (
)
func (r *RootCmd) update() *clibase.Cmd {
var (
alwaysPrompt bool
parameterFlags workspaceParameterFlags
)
var parameterFlags workspaceParameterFlags
client := new(codersdk.Client)
cmd := &clibase.Cmd{
@ -31,58 +27,16 @@ func (r *RootCmd) update() *clibase.Cmd {
if err != nil {
return err
}
if !workspace.Outdated && !alwaysPrompt && !parameterFlags.promptBuildOptions && len(parameterFlags.buildOptions) == 0 {
if !workspace.Outdated && !parameterFlags.promptRichParameters && !parameterFlags.promptBuildOptions && len(parameterFlags.buildOptions) == 0 {
_, _ = fmt.Fprintf(inv.Stdout, "Workspace isn't outdated!\n")
return nil
}
buildOptions, err := asWorkspaceBuildParameters(parameterFlags.buildOptions)
build, err := startWorkspace(inv, client, workspace, parameterFlags, WorkspaceUpdate)
if err != nil {
return err
return xerrors.Errorf("start workspace: %w", err)
}
template, err := client.Template(inv.Context(), workspace.TemplateID)
if err != nil {
return err
}
lastBuildParameters, err := client.WorkspaceBuildParameters(inv.Context(), workspace.LatestBuild.ID)
if err != nil {
return err
}
cliRichParameters, err := asWorkspaceBuildParameters(parameterFlags.richParameters)
if err != nil {
return xerrors.Errorf("can't parse given parameter values: %w", err)
}
buildParameters, err := prepWorkspaceBuild(inv, client, prepWorkspaceBuildArgs{
Action: WorkspaceUpdate,
Template: template,
NewWorkspaceName: workspace.Name,
WorkspaceID: workspace.LatestBuild.ID,
LastBuildParameters: lastBuildParameters,
PromptBuildOptions: parameterFlags.promptBuildOptions,
BuildOptions: buildOptions,
PromptRichParameters: alwaysPrompt,
RichParameters: cliRichParameters,
RichParameterFile: parameterFlags.richParameterFile,
})
if err != nil {
return err
}
build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
TemplateVersionID: template.ActiveVersionID,
Transition: codersdk.WorkspaceTransitionStart,
RichParameterValues: buildParameters,
})
if err != nil {
return err
}
logs, closer, err := client.WorkspaceBuildLogsAfter(inv.Context(), build.ID, 0)
if err != nil {
return err
@ -99,14 +53,6 @@ func (r *RootCmd) update() *clibase.Cmd {
},
}
cmd.Options = clibase.OptionSet{
{
Flag: "always-prompt",
Description: "Always prompt all parameters. Does not pull parameter values from existing workspace.",
Value: clibase.BoolOf(&alwaysPrompt),
},
}
cmd.Options = append(cmd.Options, parameterFlags.cliBuildOptions()...)
cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...)
cmd.Options = append(cmd.Options, parameterFlags.allOptions()...)
return cmd
}

View File

@ -34,6 +34,7 @@ func (r *RootCmd) vscodeSSH() *clibase.Cmd {
var (
sessionTokenFile string
urlFile string
logDir string
networkInfoDir string
networkInfoInterval time.Duration
)
@ -129,13 +130,25 @@ func (r *RootCmd) vscodeSSH() *clibase.Cmd {
}
}
var logger slog.Logger
if r.verbose {
logger = slog.Make(sloghuman.Sink(inv.Stdout)).Leveled(slog.LevelDebug)
}
// The VS Code extension obtains the PID of the SSH process to
// read files to display logs and network info.
//
// We get the parent PID because it's assumed `ssh` is calling this
// command via the ProxyCommand SSH option.
pid := os.Getppid()
var logger slog.Logger
if logDir != "" {
logFilePath := filepath.Join(logDir, fmt.Sprintf("%d.log", pid))
logFile, err := fs.OpenFile(logFilePath, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return xerrors.Errorf("open log file %q: %w", logFilePath, err)
}
defer logFile.Close()
logger = slog.Make(sloghuman.Sink(logFile)).Leveled(slog.LevelDebug)
}
if r.disableDirect {
_, _ = fmt.Fprintln(inv.Stderr, "Direct connections disabled.")
logger.Info(ctx, "direct connections disabled")
}
agentConn, err := client.DialWorkspaceAgent(ctx, agent.ID, &codersdk.DialWorkspaceAgentOptions{
Logger: logger,
@ -166,7 +179,7 @@ func (r *RootCmd) vscodeSSH() *clibase.Cmd {
//
// We get the parent PID because it's assumed `ssh` is calling this
// command via the ProxyCommand SSH option.
networkInfoFilePath := filepath.Join(networkInfoDir, fmt.Sprintf("%d.json", os.Getppid()))
networkInfoFilePath := filepath.Join(networkInfoDir, fmt.Sprintf("%d.json", pid))
statsErrChan := make(chan error, 1)
cb := func(start, end time.Time, virtual, _ map[netlogtype.Connection]netlogtype.Counts) {
@ -213,6 +226,11 @@ func (r *RootCmd) vscodeSSH() *clibase.Cmd {
Description: "Specifies a directory to write network information periodically.",
Value: clibase.StringOf(&networkInfoDir),
},
{
Flag: "log-dir",
Description: "Specifies a directory to write logs to.",
Value: clibase.StringOf(&logDir),
},
{
Flag: "session-token-file",
Description: "Specifies a file that contains a session token.",

View File

@ -43,6 +43,7 @@ func TestVSCodeSSH(t *testing.T) {
"--url-file", "/url",
"--session-token-file", "/token",
"--network-info-dir", "/net",
"--log-dir", "/log",
"--network-info-interval", "25ms",
fmt.Sprintf("coder-vscode--%s--%s", user.Username, workspace.Name),
)
@ -50,13 +51,15 @@ func TestVSCodeSSH(t *testing.T) {
waiter := clitest.StartWithWaiter(t, inv.WithContext(ctx))
assert.Eventually(t, func() bool {
entries, err := afero.ReadDir(fs, "/net")
if err != nil {
return false
}
return len(entries) > 0
}, testutil.WaitLong, testutil.IntervalFast)
for _, dir := range []string{"/net", "/log"} {
assert.Eventually(t, func() bool {
entries, err := afero.ReadDir(fs, dir)
if err != nil {
return false
}
return len(entries) > 0
}, testutil.WaitLong, testutil.IntervalFast)
}
waiter.Cancel()
if err := waiter.Wait(); err != nil {

View File

@ -934,11 +934,8 @@ func MustTransitionWorkspace(t testing.TB, client *codersdk.Client, workspaceID
require.NoError(t, err, "unexpected error fetching workspace")
require.Equal(t, workspace.LatestBuild.Transition, codersdk.WorkspaceTransition(from), "expected workspace state: %s got: %s", from, workspace.LatestBuild.Transition)
template, err := client.Template(ctx, workspace.TemplateID)
require.NoError(t, err, "fetch workspace template")
req := codersdk.CreateWorkspaceBuildRequest{
TemplateVersionID: template.ActiveVersionID,
TemplateVersionID: workspace.LatestBuild.TemplateVersionID,
Transition: codersdk.WorkspaceTransition(to),
}

View File

@ -124,6 +124,7 @@ func WorkspaceBuild(t testing.TB, db database.Store, ws database.Workspace, seed
Valid: true,
},
})
ProvisionerJobResources(t, db, jobID, seed.Transition, resources...)
seed.TemplateVersionID = templateVersion.ID
}
build := dbgen.WorkspaceBuild(t, db, seed)

View File

@ -457,6 +457,9 @@ func ConvertConfig(entries []codersdk.ExternalAuthConfig, accessURL *url.URL) ([
if entry.Type == string(codersdk.EnhancedExternalAuthProviderAzureDevops) {
oauthConfig = &jwtConfig{oc}
}
if entry.Type == string(codersdk.EnhancedExternalAuthProviderJFrog) {
oauthConfig = &exchangeWithClientSecret{oc}
}
cfg := &Config{
OAuth2Config: oauthConfig,
@ -619,3 +622,28 @@ func (c *jwtConfig) Exchange(ctx context.Context, code string, opts ...oauth2.Au
)...,
)
}
// exchangeWithClientSecret wraps an OAuth config and adds the client secret
// to the Exchange request as a Bearer header. This is used by JFrog Artifactory.
type exchangeWithClientSecret struct {
*oauth2.Config
}
func (e *exchangeWithClientSecret) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) {
httpClient, ok := ctx.Value(oauth2.HTTPClient).(*http.Client)
if httpClient == nil || !ok {
httpClient = http.DefaultClient
}
oldTransport := httpClient.Transport
httpClient.Transport = roundTripper(func(req *http.Request) (*http.Response, error) {
req.Header.Set("Authorization", "Bearer "+e.ClientSecret)
return oldTransport.RoundTrip(req)
})
return e.Config.Exchange(context.WithValue(ctx, oauth2.HTTPClient, httpClient), code, opts...)
}
type roundTripper func(req *http.Request) (*http.Response, error)
func (r roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return r(req)
}

View File

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
@ -298,6 +299,40 @@ func TestRefreshToken(t *testing.T) {
})
}
func TestExchangeWithClientSecret(t *testing.T) {
t.Parallel()
// This ensures a provider that requires the custom
// client secret exchange works.
configs, err := externalauth.ConvertConfig([]codersdk.ExternalAuthConfig{{
// JFrog just happens to require this custom type.
Type: codersdk.EnhancedExternalAuthProviderJFrog.String(),
ClientID: "id",
ClientSecret: "secret",
}}, &url.URL{})
require.NoError(t, err)
config := configs[0]
client := &http.Client{
Transport: roundTripper(func(req *http.Request) (*http.Response, error) {
require.Equal(t, "Bearer secret", req.Header.Get("Authorization"))
rec := httptest.NewRecorder()
rec.WriteHeader(http.StatusOK)
body, err := json.Marshal(&oauth2.Token{
AccessToken: "bananas",
})
if err != nil {
return nil, err
}
_, err = rec.Write(body)
return rec.Result(), err
}),
}
_, err = config.Exchange(context.WithValue(context.Background(), oauth2.HTTPClient, client), "code")
require.NoError(t, err)
}
func TestConvertYAML(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
@ -438,3 +473,9 @@ func setupOauth2Test(t *testing.T, settings testConfig) (*oidctest.FakeIDP, *ext
return fake, config, link
}
type roundTripper func(req *http.Request) (*http.Response, error)
func (r roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return r(req)
}

View File

@ -35,6 +35,7 @@ const (
EnhancedExternalAuthProviderGitLab EnhancedExternalAuthProvider = "gitlab"
EnhancedExternalAuthProviderBitBucket EnhancedExternalAuthProvider = "bitbucket"
EnhancedExternalAuthProviderSlack EnhancedExternalAuthProvider = "slack"
EnhancedExternalAuthProviderJFrog EnhancedExternalAuthProvider = "jfrog"
)
type ExternalAuth struct {

View File

@ -25,6 +25,7 @@ Coder — A tool for provisioning self-hosted development environments with Terr
| Name | Purpose |
| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| [<code>autoupdate</code>](./cli/autoupdate.md) | Toggle auto-update policy for a workspace |
| [<code>config-ssh</code>](./cli/config-ssh.md) | Add an SSH Host entry for your workspaces "ssh coder.workspace" |
| [<code>create</code>](./cli/create.md) | Create a workspace |
| [<code>delete</code>](./cli/delete.md) | Delete a workspace |

21
docs/cli/autoupdate.md generated Normal file
View File

@ -0,0 +1,21 @@
<!-- DO NOT EDIT | GENERATED CONTENT -->
# autoupdate
Toggle auto-update policy for a workspace
## Usage
```console
coder autoupdate [flags] <workspace> <always|never>
```
## Options
### -y, --yes
| | |
| ---- | ----------------- |
| Type | <code>bool</code> |
Bypass prompts.

26
docs/cli/restart.md generated
View File

@ -12,6 +12,14 @@ coder restart [flags] <workspace>
## Options
### --always-prompt
| | |
| ---- | ----------------- |
| Type | <code>bool</code> |
Always prompt all parameters. Does not pull parameter values from existing workspace.
### --build-option
| | |
@ -29,6 +37,24 @@ Build option value in the format "name=value".
Prompt for one-time build options defined with ephemeral parameters.
### --parameter
| | |
| ----------- | ---------------------------------- |
| Type | <code>string-array</code> |
| Environment | <code>$CODER_RICH_PARAMETER</code> |
Rich parameter value in the format "name=value".
### --rich-parameter-file
| | |
| ----------- | --------------------------------------- |
| Type | <code>string</code> |
| Environment | <code>$CODER_RICH_PARAMETER_FILE</code> |
Specify a file path with values for rich parameters defined in the template.
### -y, --yes
| | |

26
docs/cli/start.md generated
View File

@ -12,6 +12,14 @@ coder start [flags] <workspace>
## Options
### --always-prompt
| | |
| ---- | ----------------- |
| Type | <code>bool</code> |
Always prompt all parameters. Does not pull parameter values from existing workspace.
### --build-option
| | |
@ -29,6 +37,24 @@ Build option value in the format "name=value".
Prompt for one-time build options defined with ephemeral parameters.
### --parameter
| | |
| ----------- | ---------------------------------- |
| Type | <code>string-array</code> |
| Environment | <code>$CODER_RICH_PARAMETER</code> |
Rich parameter value in the format "name=value".
### --rich-parameter-file
| | |
| ----------- | --------------------------------------- |
| Type | <code>string</code> |
| Environment | <code>$CODER_RICH_PARAMETER_FILE</code> |
Specify a file path with values for rich parameters defined in the template.
### -y, --yes
| | |

View File

@ -569,6 +569,11 @@
"path": "./cli.md",
"icon_path": "./images/icons/terminal.svg",
"children": [
{
"title": "autoupdate",
"description": "Toggle auto-update policy for a workspace",
"path": "cli/autoupdate.md"
},
{
"title": "coder",
"path": "cli.md"

View File

@ -1,6 +1,7 @@
package cli_test
import (
"bytes"
"testing"
"github.com/google/uuid"
@ -154,21 +155,26 @@ func TestStart(t *testing.T) {
require.NoError(t, err)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, c.Client, ws.LatestBuild.ID)
initialTemplateVersion := ws.LatestBuild.TemplateVersionID
if cmd == "start" {
// Stop the workspace so that we can start it.
coderdtest.MustTransitionWorkspace(t, c.Client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop, func(req *codersdk.CreateWorkspaceBuildRequest) {
req.TemplateVersionID = oldVersion.ID
})
coderdtest.MustTransitionWorkspace(t, c.Client, ws.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop)
}
// Start the workspace. Every test permutation should
// pass.
var buf bytes.Buffer
inv, conf := newCLI(t, cmd, ws.Name, "-y")
inv.Stdout = &buf
clitest.SetupConfig(t, c.Client, conf)
err = inv.Run()
require.NoError(t, err)
ws = coderdtest.MustWorkspace(t, c.Client, ws.ID)
require.Equal(t, c.ExpectedVersion, ws.LatestBuild.TemplateVersionID)
if initialTemplateVersion != ws.LatestBuild.TemplateVersionID {
require.Contains(t, buf.String(), "Failed to restart with the template version from your last build. Policy may require you to restart with the current active template version.")
}
})
}
})

View File

@ -57,7 +57,7 @@
"chroma-js": "2.4.2",
"color-convert": "2.0.1",
"cron-parser": "4.9.0",
"cronstrue": "2.32.0",
"cronstrue": "2.41.0",
"date-fns": "2.30.0",
"dayjs": "1.11.4",
"emoji-mart": "5.4.0",
@ -67,7 +67,7 @@
"front-matter": "4.0.2",
"jest-environment-jsdom": "29.5.0",
"lodash": "4.17.21",
"monaco-editor": "0.43.0",
"monaco-editor": "0.44.0",
"pretty-bytes": "6.1.0",
"react": "18.2.0",
"react-chartjs-2": "5.2.0",
@ -86,14 +86,14 @@
"remark-gfm": "3.0.1",
"rollup-plugin-visualizer": "5.9.0",
"semver": "7.5.3",
"ts-proto": "1.159.1",
"ts-proto": "1.162.2",
"ts-prune": "0.10.3",
"tzdata": "1.0.30",
"ua-parser-js": "1.0.33",
"ufuzzy": "npm:@leeoniya/ufuzzy@1.0.10",
"unique-names-generator": "4.7.1",
"uuid": "9.0.0",
"vite": "4.4.2",
"vite": "4.5.0",
"xstate": "4.38.1",
"xterm": "5.2.0",
"xterm-addon-canvas": "0.5.0",
@ -104,7 +104,7 @@
"yup": "1.3.2"
},
"devDependencies": {
"@octokit/types": "12.0.0",
"@octokit/types": "12.1.1",
"@playwright/test": "1.38.0",
"@storybook/addon-actions": "7.5.2",
"@storybook/addon-essentials": "7.5.2",

View File

@ -35,7 +35,7 @@ dependencies:
version: 5.0.2
'@monaco-editor/react':
specifier: 4.6.0
version: 4.6.0(monaco-editor@0.43.0)(react-dom@18.2.0)(react@18.2.0)
version: 4.6.0(monaco-editor@0.44.0)(react-dom@18.2.0)(react@18.2.0)
'@mui/icons-material':
specifier: 5.14.0
version: 5.14.0(@mui/material@5.14.0)(@types/react@18.2.6)(react@18.2.0)
@ -56,7 +56,7 @@ dependencies:
version: 5.14.11(@types/react@18.2.6)(react@18.2.0)
'@vitejs/plugin-react':
specifier: 4.1.0
version: 4.1.0(vite@4.4.2)
version: 4.1.0(vite@4.5.0)
'@xstate/inspect':
specifier: 0.8.0
version: 0.8.0(ws@8.14.2)(xstate@4.38.1)
@ -91,8 +91,8 @@ dependencies:
specifier: 4.9.0
version: 4.9.0
cronstrue:
specifier: 2.32.0
version: 2.32.0
specifier: 2.41.0
version: 2.41.0
date-fns:
specifier: 2.30.0
version: 2.30.0
@ -121,8 +121,8 @@ dependencies:
specifier: 4.17.21
version: 4.17.21
monaco-editor:
specifier: 0.43.0
version: 0.43.0
specifier: 0.44.0
version: 0.44.0
pretty-bytes:
specifier: 6.1.0
version: 6.1.0
@ -178,8 +178,8 @@ dependencies:
specifier: 7.5.3
version: 7.5.3
ts-proto:
specifier: 1.159.1
version: 1.159.1
specifier: 1.162.2
version: 1.162.2
ts-prune:
specifier: 0.10.3
version: 0.10.3
@ -199,8 +199,8 @@ dependencies:
specifier: 9.0.0
version: 9.0.0
vite:
specifier: 4.4.2
version: 4.4.2(@types/node@18.18.1)
specifier: 4.5.0
version: 4.5.0(@types/node@18.18.1)
xstate:
specifier: 4.38.1
version: 4.38.1
@ -228,8 +228,8 @@ dependencies:
devDependencies:
'@octokit/types':
specifier: 12.0.0
version: 12.0.0
specifier: 12.1.1
version: 12.1.1
'@playwright/test':
specifier: 1.38.0
version: 1.38.0
@ -250,7 +250,7 @@ devDependencies:
version: 7.5.2(react-dom@18.2.0)(react@18.2.0)(typescript@5.2.2)
'@storybook/react-vite':
specifier: 7.5.2
version: 7.5.2(react-dom@18.2.0)(react@18.2.0)(typescript@5.2.2)(vite@4.4.2)
version: 7.5.2(react-dom@18.2.0)(react@18.2.0)(typescript@5.2.2)(vite@4.5.0)
'@swc/core':
specifier: 1.3.38
version: 1.3.38
@ -427,7 +427,7 @@ devDependencies:
version: 5.2.2
vite-plugin-checker:
specifier: 0.6.0
version: 0.6.0(eslint@8.52.0)(typescript@5.2.2)(vite@4.4.2)
version: 0.6.0(eslint@8.52.0)(typescript@5.2.2)(vite@4.5.0)
vite-plugin-turbosnap:
specifier: 1.0.2
version: 1.0.2
@ -2254,29 +2254,12 @@ packages:
resolution: {integrity: sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==}
dev: false
/@esbuild/android-arm64@0.18.17:
resolution: {integrity: sha512-9np+YYdNDed5+Jgr1TdWBsozZ85U1Oa3xW0c7TWqH0y2aGghXtZsuT8nYRbzOMcl0bXZXjOGbksoTtVOlWrRZg==}
engines: {node: '>=12'}
cpu: [arm64]
os: [android]
requiresBuild: true
optional: true
/@esbuild/android-arm64@0.18.20:
resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==}
engines: {node: '>=12'}
cpu: [arm64]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/android-arm@0.18.17:
resolution: {integrity: sha512-wHsmJG/dnL3OkpAcwbgoBTTMHVi4Uyou3F5mf58ZtmUyIKfcdA7TROav/6tCzET4A3QW2Q2FC+eFneMU+iyOxg==}
engines: {node: '>=12'}
cpu: [arm]
os: [android]
requiresBuild: true
optional: true
/@esbuild/android-arm@0.18.20:
@ -2285,15 +2268,6 @@ packages:
cpu: [arm]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/android-x64@0.18.17:
resolution: {integrity: sha512-O+FeWB/+xya0aLg23hHEM2E3hbfwZzjqumKMSIqcHbNvDa+dza2D0yLuymRBQQnC34CWrsJUXyH2MG5VnLd6uw==}
engines: {node: '>=12'}
cpu: [x64]
os: [android]
requiresBuild: true
optional: true
/@esbuild/android-x64@0.18.20:
@ -2302,15 +2276,6 @@ packages:
cpu: [x64]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/darwin-arm64@0.18.17:
resolution: {integrity: sha512-M9uJ9VSB1oli2BE/dJs3zVr9kcCBBsE883prage1NWz6pBS++1oNn/7soPNS3+1DGj0FrkSvnED4Bmlu1VAE9g==}
engines: {node: '>=12'}
cpu: [arm64]
os: [darwin]
requiresBuild: true
optional: true
/@esbuild/darwin-arm64@0.18.20:
@ -2319,15 +2284,6 @@ packages:
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@esbuild/darwin-x64@0.18.17:
resolution: {integrity: sha512-XDre+J5YeIJDMfp3n0279DFNrGCXlxOuGsWIkRb1NThMZ0BsrWXoTg23Jer7fEXQ9Ye5QjrvXpxnhzl3bHtk0g==}
engines: {node: '>=12'}
cpu: [x64]
os: [darwin]
requiresBuild: true
optional: true
/@esbuild/darwin-x64@0.18.20:
@ -2336,15 +2292,6 @@ packages:
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@esbuild/freebsd-arm64@0.18.17:
resolution: {integrity: sha512-cjTzGa3QlNfERa0+ptykyxs5A6FEUQQF0MuilYXYBGdBxD3vxJcKnzDlhDCa1VAJCmAxed6mYhA2KaJIbtiNuQ==}
engines: {node: '>=12'}
cpu: [arm64]
os: [freebsd]
requiresBuild: true
optional: true
/@esbuild/freebsd-arm64@0.18.20:
@ -2353,15 +2300,6 @@ packages:
cpu: [arm64]
os: [freebsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/freebsd-x64@0.18.17:
resolution: {integrity: sha512-sOxEvR8d7V7Kw8QqzxWc7bFfnWnGdaFBut1dRUYtu+EIRXefBc/eIsiUiShnW0hM3FmQ5Zf27suDuHsKgZ5QrA==}
engines: {node: '>=12'}
cpu: [x64]
os: [freebsd]
requiresBuild: true
optional: true
/@esbuild/freebsd-x64@0.18.20:
@ -2370,15 +2308,6 @@ packages:
cpu: [x64]
os: [freebsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-arm64@0.18.17:
resolution: {integrity: sha512-c9w3tE7qA3CYWjT+M3BMbwMt+0JYOp3vCMKgVBrCl1nwjAlOMYzEo+gG7QaZ9AtqZFj5MbUc885wuBBmu6aADQ==}
engines: {node: '>=12'}
cpu: [arm64]
os: [linux]
requiresBuild: true
optional: true
/@esbuild/linux-arm64@0.18.20:
@ -2387,15 +2316,6 @@ packages:
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-arm@0.18.17:
resolution: {integrity: sha512-2d3Lw6wkwgSLC2fIvXKoMNGVaeY8qdN0IC3rfuVxJp89CRfA3e3VqWifGDfuakPmp90+ZirmTfye1n4ncjv2lg==}
engines: {node: '>=12'}
cpu: [arm]
os: [linux]
requiresBuild: true
optional: true
/@esbuild/linux-arm@0.18.20:
@ -2404,15 +2324,6 @@ packages:
cpu: [arm]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-ia32@0.18.17:
resolution: {integrity: sha512-1DS9F966pn5pPnqXYz16dQqWIB0dmDfAQZd6jSSpiT9eX1NzKh07J6VKR3AoXXXEk6CqZMojiVDSZi1SlmKVdg==}
engines: {node: '>=12'}
cpu: [ia32]
os: [linux]
requiresBuild: true
optional: true
/@esbuild/linux-ia32@0.18.20:
@ -2421,15 +2332,6 @@ packages:
cpu: [ia32]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-loong64@0.18.17:
resolution: {integrity: sha512-EvLsxCk6ZF0fpCB6w6eOI2Fc8KW5N6sHlIovNe8uOFObL2O+Mr0bflPHyHwLT6rwMg9r77WOAWb2FqCQrVnwFg==}
engines: {node: '>=12'}
cpu: [loong64]
os: [linux]
requiresBuild: true
optional: true
/@esbuild/linux-loong64@0.18.20:
@ -2438,15 +2340,6 @@ packages:
cpu: [loong64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-mips64el@0.18.17:
resolution: {integrity: sha512-e0bIdHA5p6l+lwqTE36NAW5hHtw2tNRmHlGBygZC14QObsA3bD4C6sXLJjvnDIjSKhW1/0S3eDy+QmX/uZWEYQ==}
engines: {node: '>=12'}
cpu: [mips64el]
os: [linux]
requiresBuild: true
optional: true
/@esbuild/linux-mips64el@0.18.20:
@ -2455,15 +2348,6 @@ packages:
cpu: [mips64el]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-ppc64@0.18.17:
resolution: {integrity: sha512-BAAilJ0M5O2uMxHYGjFKn4nJKF6fNCdP1E0o5t5fvMYYzeIqy2JdAP88Az5LHt9qBoUa4tDaRpfWt21ep5/WqQ==}
engines: {node: '>=12'}
cpu: [ppc64]
os: [linux]
requiresBuild: true
optional: true
/@esbuild/linux-ppc64@0.18.20:
@ -2472,15 +2356,6 @@ packages:
cpu: [ppc64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-riscv64@0.18.17:
resolution: {integrity: sha512-Wh/HW2MPnC3b8BqRSIme/9Zhab36PPH+3zam5pqGRH4pE+4xTrVLx2+XdGp6fVS3L2x+DrsIcsbMleex8fbE6g==}
engines: {node: '>=12'}
cpu: [riscv64]
os: [linux]
requiresBuild: true
optional: true
/@esbuild/linux-riscv64@0.18.20:
@ -2489,15 +2364,6 @@ packages:
cpu: [riscv64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-s390x@0.18.17:
resolution: {integrity: sha512-j/34jAl3ul3PNcK3pfI0NSlBANduT2UO5kZ7FCaK33XFv3chDhICLY8wJJWIhiQ+YNdQ9dxqQctRg2bvrMlYgg==}
engines: {node: '>=12'}
cpu: [s390x]
os: [linux]
requiresBuild: true
optional: true
/@esbuild/linux-s390x@0.18.20:
@ -2506,15 +2372,6 @@ packages:
cpu: [s390x]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-x64@0.18.17:
resolution: {integrity: sha512-QM50vJ/y+8I60qEmFxMoxIx4de03pGo2HwxdBeFd4nMh364X6TIBZ6VQ5UQmPbQWUVWHWws5MmJXlHAXvJEmpQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [linux]
requiresBuild: true
optional: true
/@esbuild/linux-x64@0.18.20:
@ -2523,15 +2380,6 @@ packages:
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/netbsd-x64@0.18.17:
resolution: {integrity: sha512-/jGlhWR7Sj9JPZHzXyyMZ1RFMkNPjC6QIAan0sDOtIo2TYk3tZn5UDrkE0XgsTQCxWTTOcMPf9p6Rh2hXtl5TQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [netbsd]
requiresBuild: true
optional: true
/@esbuild/netbsd-x64@0.18.20:
@ -2540,15 +2388,6 @@ packages:
cpu: [x64]
os: [netbsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/openbsd-x64@0.18.17:
resolution: {integrity: sha512-rSEeYaGgyGGf4qZM2NonMhMOP/5EHp4u9ehFiBrg7stH6BYEEjlkVREuDEcQ0LfIl53OXLxNbfuIj7mr5m29TA==}
engines: {node: '>=12'}
cpu: [x64]
os: [openbsd]
requiresBuild: true
optional: true
/@esbuild/openbsd-x64@0.18.20:
@ -2557,15 +2396,6 @@ packages:
cpu: [x64]
os: [openbsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/sunos-x64@0.18.17:
resolution: {integrity: sha512-Y7ZBbkLqlSgn4+zot4KUNYst0bFoO68tRgI6mY2FIM+b7ZbyNVtNbDP5y8qlu4/knZZ73fgJDlXID+ohY5zt5g==}
engines: {node: '>=12'}
cpu: [x64]
os: [sunos]
requiresBuild: true
optional: true
/@esbuild/sunos-x64@0.18.20:
@ -2574,15 +2404,6 @@ packages:
cpu: [x64]
os: [sunos]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-arm64@0.18.17:
resolution: {integrity: sha512-bwPmTJsEQcbZk26oYpc4c/8PvTY3J5/QK8jM19DVlEsAB41M39aWovWoHtNm78sd6ip6prilxeHosPADXtEJFw==}
engines: {node: '>=12'}
cpu: [arm64]
os: [win32]
requiresBuild: true
optional: true
/@esbuild/win32-arm64@0.18.20:
@ -2591,15 +2412,6 @@ packages:
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-ia32@0.18.17:
resolution: {integrity: sha512-H/XaPtPKli2MhW+3CQueo6Ni3Avggi6hP/YvgkEe1aSaxw+AeO8MFjq8DlgfTd9Iz4Yih3QCZI6YLMoyccnPRg==}
engines: {node: '>=12'}
cpu: [ia32]
os: [win32]
requiresBuild: true
optional: true
/@esbuild/win32-ia32@0.18.20:
@ -2608,15 +2420,6 @@ packages:
cpu: [ia32]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-x64@0.18.17:
resolution: {integrity: sha512-fGEb8f2BSA3CW7riJVurug65ACLuQAzKq0SSqkY2b2yHHH0MzDfbLyKIGzHwOI/gkHcxM/leuSW6D5w/LMNitA==}
engines: {node: '>=12'}
cpu: [x64]
os: [win32]
requiresBuild: true
optional: true
/@esbuild/win32-x64@0.18.20:
@ -2625,7 +2428,6 @@ packages:
cpu: [x64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@eslint-community/eslint-utils@4.4.0(eslint@8.52.0):
@ -3003,7 +2805,7 @@ packages:
'@types/yargs': 17.0.29
chalk: 4.1.2
/@joshwooding/vite-plugin-react-docgen-typescript@0.3.0(typescript@5.2.2)(vite@4.4.2):
/@joshwooding/vite-plugin-react-docgen-typescript@0.3.0(typescript@5.2.2)(vite@4.5.0):
resolution: {integrity: sha512-2D6y7fNvFmsLmRt6UCOFJPvFoPMJGT0Uh1Wg0RaigUp7kdQPs6yYn8Dmx6GZkOH/NW0yMTwRz/p0SRMMRo50vA==}
peerDependencies:
typescript: '>= 4.3.x'
@ -3017,7 +2819,7 @@ packages:
magic-string: 0.27.0
react-docgen-typescript: 2.2.2(typescript@5.2.2)
typescript: 5.2.2
vite: 4.4.2(@types/node@18.18.1)
vite: 4.5.0(@types/node@18.18.1)
dev: true
/@jridgewell/gen-mapping@0.3.3:
@ -3096,24 +2898,24 @@ packages:
react: 18.2.0
dev: true
/@monaco-editor/loader@1.4.0(monaco-editor@0.43.0):
/@monaco-editor/loader@1.4.0(monaco-editor@0.44.0):
resolution: {integrity: sha512-00ioBig0x642hytVspPl7DbQyaSWRaolYie/UFNjoTdvoKPzo6xrXLhTk9ixgIKcLH5b5vDOjVNiGyY+uDCUlg==}
peerDependencies:
monaco-editor: '>= 0.21.0 < 1'
dependencies:
monaco-editor: 0.43.0
monaco-editor: 0.44.0
state-local: 1.0.7
dev: false
/@monaco-editor/react@4.6.0(monaco-editor@0.43.0)(react-dom@18.2.0)(react@18.2.0):
/@monaco-editor/react@4.6.0(monaco-editor@0.44.0)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-RFkU9/i7cN2bsq/iTkurMWOEErmYcY6JiQI3Jn+WeR/FGISH8JbHERjpS9oRuSOPvDMJI0Z8nJeKkbOs9sBYQw==}
peerDependencies:
monaco-editor: '>= 0.25.0 < 1'
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
dependencies:
'@monaco-editor/loader': 1.4.0(monaco-editor@0.43.0)
monaco-editor: 0.43.0
'@monaco-editor/loader': 1.4.0(monaco-editor@0.44.0)
monaco-editor: 0.44.0
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
@ -3436,14 +3238,14 @@ packages:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.15.0
/@octokit/openapi-types@19.0.0:
resolution: {integrity: sha512-PclQ6JGMTE9iUStpzMkwLCISFn/wDeRjkZFIKALpvJQNBGwDoYYi2fFvuHwssoQ1rXI5mfh6jgTgWuddeUzfWw==}
/@octokit/openapi-types@19.0.2:
resolution: {integrity: sha512-8li32fUDUeml/ACRp/njCWTsk5t17cfTM1jp9n08pBrqs5cDFJubtjsSnuz56r5Tad6jdEPJld7LxNp9dNcyjQ==}
dev: true
/@octokit/types@12.0.0:
resolution: {integrity: sha512-EzD434aHTFifGudYAygnFlS1Tl6KhbTynEWELQXIbTY8Msvb5nEqTZIm7sbPEt4mQYLZwu3zPKVdeIrw0g7ovg==}
/@octokit/types@12.1.1:
resolution: {integrity: sha512-qnJTldJ1NyGT5MTsCg/Zi+y2IFHZ1Jo5+njNCjJ9FcainV7LjuHgmB697kA0g4MjZeDAJsM3B45iqCVsCLVFZg==}
dependencies:
'@octokit/openapi-types': 19.0.0
'@octokit/openapi-types': 19.0.2
dev: true
/@open-draft/until@1.0.3:
@ -4510,7 +4312,7 @@ packages:
- supports-color
dev: true
/@storybook/builder-vite@7.5.2(typescript@5.2.2)(vite@4.4.2):
/@storybook/builder-vite@7.5.2(typescript@5.2.2)(vite@4.5.0):
resolution: {integrity: sha512-j96m5K0ahlAjQY6uUxEbybvmRFc3eMpQ3wiosuunc8NkXtfohXZeRVQowAcVrfPktKMufRNGY86RTYxe7sMABw==}
peerDependencies:
'@preact/preset-vite': '*'
@ -4542,7 +4344,7 @@ packages:
magic-string: 0.30.5
rollup: 3.29.4
typescript: 5.2.2
vite: 4.4.2(@types/node@18.18.1)
vite: 4.5.0(@types/node@18.18.1)
transitivePeerDependencies:
- encoding
- supports-color
@ -4916,7 +4718,7 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: true
/@storybook/react-vite@7.5.2(react-dom@18.2.0)(react@18.2.0)(typescript@5.2.2)(vite@4.4.2):
/@storybook/react-vite@7.5.2(react-dom@18.2.0)(react@18.2.0)(typescript@5.2.2)(vite@4.5.0):
resolution: {integrity: sha512-faYGER/qU/jeaMEf5kgx4dNeKno+HkCEviXo/bgRswRg7odW5XydlGGSATOYLYxLhWG6jztaYHYIaDk21KoOVA==}
engines: {node: '>=16'}
peerDependencies:
@ -4924,16 +4726,16 @@ packages:
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
vite: ^3.0.0 || ^4.0.0 || ^5.0.0
dependencies:
'@joshwooding/vite-plugin-react-docgen-typescript': 0.3.0(typescript@5.2.2)(vite@4.4.2)
'@joshwooding/vite-plugin-react-docgen-typescript': 0.3.0(typescript@5.2.2)(vite@4.5.0)
'@rollup/pluginutils': 5.0.5
'@storybook/builder-vite': 7.5.2(typescript@5.2.2)(vite@4.4.2)
'@storybook/builder-vite': 7.5.2(typescript@5.2.2)(vite@4.5.0)
'@storybook/react': 7.5.2(react-dom@18.2.0)(react@18.2.0)(typescript@5.2.2)
'@vitejs/plugin-react': 3.1.0(vite@4.4.2)
'@vitejs/plugin-react': 3.1.0(vite@4.5.0)
magic-string: 0.30.5
react: 18.2.0
react-docgen: 6.0.4
react-dom: 18.2.0(react@18.2.0)
vite: 4.4.2(@types/node@18.18.1)
vite: 4.5.0(@types/node@18.18.1)
transitivePeerDependencies:
- '@preact/preset-vite'
- encoding
@ -5955,7 +5757,7 @@ packages:
/@ungap/structured-clone@1.2.0:
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
/@vitejs/plugin-react@3.1.0(vite@4.4.2):
/@vitejs/plugin-react@3.1.0(vite@4.5.0):
resolution: {integrity: sha512-AfgcRL8ZBhAlc3BFdigClmTUMISmmzHn7sB2h9U1odvc5U/MjWXsAaz18b/WoppUTDBzxOJwo2VdClfUcItu9g==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
@ -5966,12 +5768,12 @@ packages:
'@babel/plugin-transform-react-jsx-source': 7.22.5(@babel/core@7.23.2)
magic-string: 0.27.0
react-refresh: 0.14.0
vite: 4.4.2(@types/node@18.18.1)
vite: 4.5.0(@types/node@18.18.1)
transitivePeerDependencies:
- supports-color
dev: true
/@vitejs/plugin-react@4.1.0(vite@4.4.2):
/@vitejs/plugin-react@4.1.0(vite@4.5.0):
resolution: {integrity: sha512-rM0SqazU9iqPUraQ2JlIvReeaxOoRj6n+PzB1C0cBzIbd8qP336nC39/R9yPi3wVcah7E7j/kdU1uCUqMEU4OQ==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
@ -5982,7 +5784,7 @@ packages:
'@babel/plugin-transform-react-jsx-source': 7.22.5(@babel/core@7.23.0)
'@types/babel__core': 7.20.2
react-refresh: 0.14.0
vite: 4.4.2(@types/node@18.18.1)
vite: 4.5.0(@types/node@18.18.1)
transitivePeerDependencies:
- supports-color
dev: false
@ -7217,8 +7019,8 @@ packages:
luxon: 3.3.0
dev: false
/cronstrue@2.32.0:
resolution: {integrity: sha512-dmNflOCNJL6lZEj0dp2YhGIPY83VTjFue6d9feFhnNtrER6mAjBrUvSgK95j3IB/xNGpLjaZDIDG6ACKTZr9Yw==}
/cronstrue@2.41.0:
resolution: {integrity: sha512-3ZS3eMJaxMRBGmDauKCKbyIRgVcph6uSpkhSbbZvvJWkelHiSTzGJbBqmu8io7Hspd2F45bQKnC1kzoNvtku2g==}
hasBin: true
dev: false
@ -7799,35 +7601,6 @@ packages:
- supports-color
dev: true
/esbuild@0.18.17:
resolution: {integrity: sha512-1GJtYnUxsJreHYA0Y+iQz2UEykonY66HNWOb0yXYZi9/kNrORUEHVg87eQsCtqh59PEJ5YVZJO98JHznMJSWjg==}
engines: {node: '>=12'}
hasBin: true
requiresBuild: true
optionalDependencies:
'@esbuild/android-arm': 0.18.17
'@esbuild/android-arm64': 0.18.17
'@esbuild/android-x64': 0.18.17
'@esbuild/darwin-arm64': 0.18.17
'@esbuild/darwin-x64': 0.18.17
'@esbuild/freebsd-arm64': 0.18.17
'@esbuild/freebsd-x64': 0.18.17
'@esbuild/linux-arm': 0.18.17
'@esbuild/linux-arm64': 0.18.17
'@esbuild/linux-ia32': 0.18.17
'@esbuild/linux-loong64': 0.18.17
'@esbuild/linux-mips64el': 0.18.17
'@esbuild/linux-ppc64': 0.18.17
'@esbuild/linux-riscv64': 0.18.17
'@esbuild/linux-s390x': 0.18.17
'@esbuild/linux-x64': 0.18.17
'@esbuild/netbsd-x64': 0.18.17
'@esbuild/openbsd-x64': 0.18.17
'@esbuild/sunos-x64': 0.18.17
'@esbuild/win32-arm64': 0.18.17
'@esbuild/win32-ia32': 0.18.17
'@esbuild/win32-x64': 0.18.17
/esbuild@0.18.20:
resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
engines: {node: '>=12'}
@ -7856,7 +7629,6 @@ packages:
'@esbuild/win32-arm64': 0.18.20
'@esbuild/win32-ia32': 0.18.20
'@esbuild/win32-x64': 0.18.20
dev: true
/escalade@3.1.1:
resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==}
@ -11006,8 +10778,8 @@ packages:
engines: {node: '>= 8'}
dev: true
/monaco-editor@0.43.0:
resolution: {integrity: sha512-cnoqwQi/9fml2Szamv1XbSJieGJ1Dc8tENVMD26Kcfl7xGQWp7OBKMjlwKVGYFJ3/AXJjSOGvcqK7Ry/j9BM1Q==}
/monaco-editor@0.44.0:
resolution: {integrity: sha512-5SmjNStN6bSuSE5WPT2ZV+iYn1/yI9sd4Igtk23ChvqB7kDk9lZbB9F5frsuvpB+2njdIeGGFf2G4gbE6rCC9Q==}
dev: false
/moo-color@1.0.3:
@ -12490,20 +12262,12 @@ packages:
yargs: 17.7.2
dev: false
/rollup@3.26.3:
resolution: {integrity: sha512-7Tin0C8l86TkpcMtXvQu6saWH93nhG3dGQ1/+l5V2TDMceTxO7kDiK6GzbfLWNNxqJXm591PcEZUozZm51ogwQ==}
engines: {node: '>=14.18.0', npm: '>=8.0.0'}
hasBin: true
optionalDependencies:
fsevents: 2.3.3
/rollup@3.29.4:
resolution: {integrity: sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==}
engines: {node: '>=14.18.0', npm: '>=8.0.0'}
hasBin: true
optionalDependencies:
fsevents: 2.3.3
dev: true
/rtl-css-js@1.16.1:
resolution: {integrity: sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==}
@ -13360,8 +13124,8 @@ packages:
protobufjs: 7.2.4
dev: false
/ts-proto@1.159.1:
resolution: {integrity: sha512-pxzjxLXZXQtpjQvtZGqqksgjvcXc9kokO4X8nXvMenL3rqnw2V5tMExNs5xaZj1m8DkCrgHKw5TtjV1/x+GgEA==}
/ts-proto@1.162.2:
resolution: {integrity: sha512-iVvoXmelsniHFxh9GAkmz3S7yNuddjgv5iWTDr0VUn67IH3RSvvAd8BjN7Snm0+p1yY/diAQoNHXHuNHe8D3rA==}
hasBin: true
dependencies:
case-anything: 2.1.13
@ -13831,7 +13595,7 @@ packages:
unist-util-stringify-position: 3.0.3
vfile-message: 3.1.4
/vite-plugin-checker@0.6.0(eslint@8.52.0)(typescript@5.2.2)(vite@4.4.2):
/vite-plugin-checker@0.6.0(eslint@8.52.0)(typescript@5.2.2)(vite@4.5.0):
resolution: {integrity: sha512-DWZ9Hv2TkpjviPxAelNUt4Q3IhSGrx7xrwdM64NI+Q4dt8PaMWJJh4qGNtSrfEuiuIzWWo00Ksvh5It4Y3L9xQ==}
engines: {node: '>=14.16'}
peerDependencies:
@ -13877,7 +13641,7 @@ packages:
strip-ansi: 6.0.1
tiny-invariant: 1.3.1
typescript: 5.2.2
vite: 4.4.2(@types/node@18.18.1)
vite: 4.5.0(@types/node@18.18.1)
vscode-languageclient: 7.0.0
vscode-languageserver: 7.0.0
vscode-languageserver-textdocument: 1.0.8
@ -13888,8 +13652,8 @@ packages:
resolution: {integrity: sha512-irjKcKXRn7v5bPAg4mAbsS6DgibpP1VUFL9tlgxU6lloK6V9yw9qCZkS+s2PtbkZpWNzr3TN3zVJAc6J7gJZmA==}
dev: true
/vite@4.4.2(@types/node@18.18.1):
resolution: {integrity: sha512-zUcsJN+UvdSyHhYa277UHhiJ3iq4hUBwHavOpsNUGsTgjBeoBlK8eDt+iT09pBq0h9/knhG/SPrZiM7cGmg7NA==}
/vite@4.5.0(@types/node@18.18.1):
resolution: {integrity: sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==}
engines: {node: ^14.18.0 || >=16.0.0}
hasBin: true
peerDependencies:
@ -13917,9 +13681,9 @@ packages:
optional: true
dependencies:
'@types/node': 18.18.1
esbuild: 0.18.17
esbuild: 0.18.20
postcss: 8.4.31
rollup: 3.26.3
rollup: 3.29.4
optionalDependencies:
fsevents: 2.3.3

View File

@ -5,3 +5,10 @@ export const uploadFile = () => {
mutationFn: API.uploadFile,
};
};
export const file = (fileId: string) => {
return {
queryKey: ["files", fileId],
queryFn: () => API.getFile(fileId),
};
};

View File

@ -129,6 +129,15 @@ export const templateVersionVariables = (versionId: string) => {
};
};
export const createTemplateVersion = (orgId: string) => {
return {
mutationFn: async (request: CreateTemplateVersionRequest) => {
const newVersion = await API.createTemplateVersion(orgId, request);
return newVersion;
},
};
};
export const createAndBuildTemplateVersion = (orgId: string) => {
return {
mutationFn: async (request: CreateTemplateVersionRequest) => {
@ -216,6 +225,13 @@ export const richParameters = (versionId: string) => {
};
};
export const resources = (versionId: string) => {
return {
queryKey: ["templateVersion", versionId, "resources"],
queryFn: () => API.getTemplateVersionResources(versionId),
};
};
const waitBuildToBeFinished = async (version: TemplateVersion) => {
let data: TemplateVersion;
let jobStatus: ProvisionerJobStatus;

View File

@ -1,6 +1,21 @@
import { UseInfiniteQueryOptions } from "react-query";
import { QueryOptions, UseInfiniteQueryOptions } from "react-query";
import * as API from "api/api";
import { WorkspaceBuild, WorkspaceBuildsRequest } from "api/typesGenerated";
import {
type WorkspaceBuild,
type WorkspaceBuildParameter,
type WorkspaceBuildsRequest,
} from "api/typesGenerated";
export function workspaceBuildParametersKey(workspaceBuildId: string) {
return ["workspaceBuilds", workspaceBuildId, "parameters"] as const;
}
export function workspaceBuildParameters(workspaceBuildId: string) {
return {
queryKey: workspaceBuildParametersKey(workspaceBuildId),
queryFn: () => API.getWorkspaceBuildParameters(workspaceBuildId),
} as const satisfies QueryOptions<WorkspaceBuildParameter[]>;
}
export const workspaceBuildByNumber = (
username: string,

View File

@ -1684,12 +1684,14 @@ export type EnhancedExternalAuthProvider =
| "bitbucket"
| "github"
| "gitlab"
| "jfrog"
| "slack";
export const EnhancedExternalAuthProviders: EnhancedExternalAuthProvider[] = [
"azure-devops",
"bitbucket",
"github",
"gitlab",
"jfrog",
"slack",
];

View File

@ -21,8 +21,15 @@ export const Alert: FC<AlertProps> = ({
}) => {
const [open, setOpen] = useState(true);
// Can't only rely on MUI's hiding behavior inside flex layouts, because even
// though MUI will make a dismissed alert have zero height, the alert will
// still behave as a flex child and introduce extra row/column gaps
if (!open) {
return null;
}
return (
<Collapse in={open}>
<Collapse in>
<MuiAlert
{...alertProps}
sx={{ textAlign: "left", ...alertProps.sx }}

View File

@ -179,20 +179,20 @@ export const DeploymentBannerView: FC<DeploymentBannerViewProps> = (props) => {
We have detected problems with your Coder deployment.
</HelpTooltipTitle>
<Stack spacing={1}>
{health.access_url && (
{!health.access_url.healthy && (
<HealthIssue>
Your access URL may be configured incorrectly.
</HealthIssue>
)}
{health.database && (
{!health.database.healthy && (
<HealthIssue>Your database is unhealthy.</HealthIssue>
)}
{health.derp && (
{!health.derp.healthy && (
<HealthIssue>
We&apos;re noticing DERP proxy issues.
</HealthIssue>
)}
{health.websocket && (
{!health.websocket.healthy && (
<HealthIssue>
We&apos;re noticing websocket issues.
</HealthIssue>
@ -406,8 +406,8 @@ const WorkspaceBuildValue: FC<{
const HealthIssue: FC<PropsWithChildren> = ({ children }) => {
return (
<Stack direction="row" spacing={1}>
<ErrorIcon fontSize="small" htmlColor={colors.red[10]} />
<Stack direction="row" spacing={1} alignItems="center">
<ErrorIcon css={{ width: 16, height: 16 }} htmlColor={colors.red[10]} />
{children}
</Stack>
);

View File

@ -304,9 +304,9 @@ const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({
if (isLoading) {
return (
<Skeleton
width="160px"
width="110px"
height={BUTTON_SM_HEIGHT}
sx={{ borderRadius: "4px", transform: "none" }}
sx={{ borderRadius: "9999px", transform: "none" }}
/>
);
}
@ -319,13 +319,13 @@ const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({
size="small"
endIcon={<KeyboardArrowDownOutlined />}
sx={{
borderRadius: "4px",
borderRadius: "999px",
"& .MuiSvgIcon-root": { fontSize: 14 },
}}
>
{selectedProxy ? (
<Box display="flex" gap={2} alignItems="center">
<Box width={14} height={14} lineHeight={0}>
<Box display="flex" gap={1} alignItems="center">
<Box width={16} height={16} lineHeight={0}>
<Box
component="img"
src={selectedProxy.icon_url}
@ -335,7 +335,6 @@ const ProxyMenu: FC<{ proxyContextValue: ProxyContextValue }> = ({
height="100%"
/>
</Box>
{selectedProxy.display_name}
<ProxyStatusLatency
latency={latencies?.[selectedProxy.id]?.latencyMS}
isLoading={proxyLatencyLoading(selectedProxy)}

View File

@ -76,6 +76,7 @@ export const UserDropdown: FC<PropsWithChildren<UserDropdownProps>> = ({
horizontal="right"
css={(theme) => ({
".MuiPaper-root": {
minWidth: "auto",
width: 260,
boxShadow: theme.shadows[6],
},

View File

@ -55,3 +55,12 @@ export const SuccessDialogWithCancel: Story = {
type: "success",
},
};
export const SuccessDialogLoading: Story = {
args: {
description: "I am successful.",
hideCancel: true,
type: "success",
confirmLoading: true,
},
};

View File

@ -32,10 +32,7 @@ const CONFIRM_DIALOG_DEFAULTS: Record<
};
export interface ConfirmDialogProps
extends Omit<
DialogActionButtonsProps,
"color" | "confirmDialog" | "onCancel"
> {
extends Omit<DialogActionButtonsProps, "color" | "onCancel"> {
readonly description?: ReactNode;
/**
* hideCancel hides the cancel button when set true, and shows the cancel
@ -135,7 +132,6 @@ export const ConfirmDialog: FC<PropsWithChildren<ConfirmDialogProps>> = ({
<DialogActions>
<DialogActionButtons
cancelText={cancelText}
confirmDialog
confirmLoading={confirmLoading}
confirmText={confirmText || defaults.confirmText}
disabled={disabled}

View File

@ -16,8 +16,15 @@ const meta: Meta<typeof DeleteDialog> = {
};
export default meta;
type Story = StoryObj<typeof DeleteDialog>;
const Example: Story = {};
export const Loading: Story = {
args: {
confirmLoading: true,
},
};
export { Example as DeleteDialog };

View File

@ -12,8 +12,6 @@ export interface DialogActionButtonsProps {
confirmText?: ReactNode;
/** Whether or not confirm is loading, also disables cancel when true */
confirmLoading?: boolean;
/** Whether or not this is a confirm dialog */
confirmDialog?: boolean;
/** Whether or not the submit button is disabled */
disabled?: boolean;
/** Called when cancel is clicked */
@ -49,6 +47,7 @@ export const DialogActionButtons: React.FC<DialogActionButtonsProps> = ({
{cancelText}
</LoadingButton>
)}
{onConfirm && (
<LoadingButton
fullWidth
@ -76,7 +75,10 @@ const styles = {
"&.MuiButton-contained": {
backgroundColor: colors.red[10],
borderColor: colors.red[9],
color: theme.palette.text.primary,
"&:not(.MuiLoadingButton-loading)": {
color: theme.palette.text.primary,
},
"&:hover:not(:disabled)": {
backgroundColor: colors.red[9],
@ -86,26 +88,39 @@ const styles = {
"&.Mui-disabled": {
backgroundColor: colors.red[15],
borderColor: colors.red[15],
color: colors.red[9],
"&:not(.MuiLoadingButton-loading)": {
color: colors.red[9],
},
},
},
}),
successButton: (theme) => ({
"&.MuiButton-contained": {
backgroundColor: theme.palette.success.main,
color: theme.palette.primary.contrastText,
"&:not(.MuiLoadingButton-loading)": {
color: theme.palette.primary.contrastText,
},
"&:hover": {
backgroundColor: theme.palette.success.dark,
"@media (hover: none)": {
backgroundColor: "transparent",
},
"&.Mui-disabled": {
backgroundColor: "transparent",
},
},
"&.Mui-disabled": {
backgroundColor: theme.palette.action.disabledBackground,
color: theme.palette.text.secondary,
backgroundColor: theme.palette.success.dark,
"&:not(.MuiLoadingButton-loading)": {
color: theme.palette.text.secondary,
},
},
},

View File

@ -1,9 +1,9 @@
import { css, Global, useTheme } from "@emotion/react";
import Button from "@mui/material/Button";
import InputAdornment from "@mui/material/InputAdornment";
import TextField, { TextFieldProps } from "@mui/material/TextField";
import { makeStyles } from "@mui/styles";
import TextField, { type TextFieldProps } from "@mui/material/TextField";
import Picker from "@emoji-mart/react";
import { FC } from "react";
import { type FC } from "react";
import { DropdownArrow } from "components/DropdownArrow/DropdownArrow";
import { Stack } from "components/Stack/Stack";
import { colors } from "theme/colors";
@ -48,7 +48,7 @@ const IconField: FC<IconFieldProps> = ({ onPickEmoji, ...textFieldProps }) => {
throw new Error(`Invalid icon value "${typeof textFieldProps.value}"`);
}
const styles = useStyles();
const theme = useTheme();
const hasIcon = textFieldProps.value && textFieldProps.value !== "";
return (
@ -59,7 +59,21 @@ const IconField: FC<IconFieldProps> = ({ onPickEmoji, ...textFieldProps }) => {
label="Icon"
InputProps={{
endAdornment: hasIcon ? (
<InputAdornment position="end" className={styles.adornment}>
<InputAdornment
position="end"
css={{
width: theme.spacing(3),
height: theme.spacing(3),
display: "flex",
alignItems: "center",
justifyContent: "center",
"& img": {
maxWidth: "100%",
objectFit: "contain",
},
}}
>
<img
alt=""
src={textFieldProps.value}
@ -85,6 +99,18 @@ const IconField: FC<IconFieldProps> = ({ onPickEmoji, ...textFieldProps }) => {
id="emoji"
css={{ marginTop: 0, ".MuiPaper-root": { width: "auto" } }}
>
<Global
styles={css`
em-emoji-picker {
--rgb-background: ${theme.palette.background.paper};
--rgb-input: ${colors.gray[17]};
--rgb-color: ${colors.gray[4]};
// Hack to prevent the right side from being cut off
width: 350px;
}
`}
/>
<Picker
set="twitter"
theme="dark"
@ -104,29 +130,4 @@ const IconField: FC<IconFieldProps> = ({ onPickEmoji, ...textFieldProps }) => {
);
};
const useStyles = makeStyles((theme) => ({
"@global": {
"em-emoji-picker": {
"--rgb-background": theme.palette.background.paper,
"--rgb-input": colors.gray[17],
"--rgb-color": colors.gray[4],
// Hack to prevent the right side from being cut off
width: 350,
},
},
adornment: {
width: theme.spacing(3),
height: theme.spacing(3),
display: "flex",
alignItems: "center",
justifyContent: "center",
"& img": {
maxWidth: "100%",
objectFit: "contain",
},
},
}));
export default IconField;

View File

@ -0,0 +1,108 @@
import { useRef, useState, createContext, useContext, ReactNode } from "react";
import MoreVertOutlined from "@mui/icons-material/MoreVertOutlined";
import Menu, { MenuProps } from "@mui/material/Menu";
import MenuItem, { MenuItemProps } from "@mui/material/MenuItem";
import IconButton, { IconButtonProps } from "@mui/material/IconButton";
type MoreMenuContextValue = {
triggerRef: React.RefObject<HTMLButtonElement>;
close: () => void;
open: () => void;
isOpen: boolean;
};
const MoreMenuContext = createContext<MoreMenuContextValue | undefined>(
undefined,
);
export const MoreMenu = (props: { children: ReactNode }) => {
const triggerRef = useRef<HTMLButtonElement>(null);
const [isOpen, setIsOpen] = useState(false);
const close = () => {
setIsOpen(false);
};
const open = () => {
setIsOpen(true);
};
return (
<MoreMenuContext.Provider value={{ close, open, triggerRef, isOpen }}>
{props.children}
</MoreMenuContext.Provider>
);
};
const useMoreMenuContext = () => {
const ctx = useContext(MoreMenuContext);
if (!ctx) {
throw new Error("useMoreMenuContext must be used inside of MoreMenu");
}
return ctx;
};
export const MoreMenuTrigger = (props: IconButtonProps) => {
const menu = useMoreMenuContext();
return (
<IconButton
aria-controls="more-options"
aria-label="More options"
aria-haspopup="true"
onClick={menu.open}
ref={menu.triggerRef}
{...props}
>
<MoreVertOutlined />
</IconButton>
);
};
export const MoreMenuContent = (props: Omit<MenuProps, "open" | "onClose">) => {
const menu = useMoreMenuContext();
return (
<Menu
id="more-options"
anchorEl={menu.triggerRef.current}
open={menu.isOpen}
onClose={menu.close}
disablePortal
{...props}
/>
);
};
export const MoreMenuItem = (
props: MenuItemProps & { closeOnClick?: boolean; danger?: boolean },
) => {
const { closeOnClick = true, danger = false, ...menuItemProps } = props;
const ctx = useContext(MoreMenuContext);
if (!ctx) {
throw new Error("MoreMenuItem must be used inside of MoreMenu");
}
return (
<MenuItem
{...menuItemProps}
css={(theme) => ({
fontSize: 14,
color: danger ? theme.palette.error.light : undefined,
"& .MuiSvgIcon-root": {
width: theme.spacing(2),
height: theme.spacing(2),
},
})}
onClick={(e) => {
menuItemProps.onClick && menuItemProps.onClick(e);
if (closeOnClick) {
ctx.close();
}
}}
/>
);
};

View File

@ -1,6 +1,6 @@
import { type CSSObject, type Interpolation, type Theme } from "@emotion/react";
import Box from "@mui/material/Box";
import { type ComponentProps, type FC } from "react";
import { ReactNode, type ComponentProps, type FC } from "react";
export const Stats: FC<ComponentProps<typeof Box>> = (props) => {
return <Box {...props} css={styles.stats} />;
@ -9,7 +9,7 @@ export const Stats: FC<ComponentProps<typeof Box>> = (props) => {
export const StatsItem: FC<
{
label: string;
value: string | number | JSX.Element;
value: ReactNode;
} & ComponentProps<typeof Box>
> = ({ label, value, ...divProps }) => {
return (

View File

@ -1,24 +0,0 @@
import { TableRowMenu } from "./TableRowMenu";
import type { Meta, StoryObj } from "@storybook/react";
const meta: Meta<typeof TableRowMenu> = {
title: "components/TableRowMenu",
component: TableRowMenu,
};
export default meta;
type Story = StoryObj<typeof TableRowMenu<{ id: string }>>;
const Example: Story = {
args: {
data: { id: "123" },
menuItems: [
{ label: "Suspend", onClick: (data) => alert(data.id), disabled: false },
{ label: "Update", onClick: (data) => alert(data.id), disabled: false },
{ label: "Delete", onClick: (data) => alert(data.id), disabled: false },
{ label: "Explode", onClick: (data) => alert(data.id), disabled: true },
],
},
};
export { Example as TableRowMenu };

View File

@ -1,63 +0,0 @@
import IconButton from "@mui/material/IconButton";
import Menu, { MenuProps } from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import { MouseEvent, useState } from "react";
export interface TableRowMenuProps<TData> {
data: TData;
menuItems: Array<{
label: React.ReactNode;
disabled: boolean;
onClick: (data: TData) => void;
}>;
}
export const TableRowMenu = <T,>({
data,
menuItems,
}: TableRowMenuProps<T>): JSX.Element => {
const [anchorEl, setAnchorEl] = useState<MenuProps["anchorEl"]>(null);
const handleClick = (event: MouseEvent) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<>
<IconButton
size="small"
aria-label="more"
aria-controls="long-menu"
aria-haspopup="true"
onClick={handleClick}
>
<MoreVertIcon />
</IconButton>
<Menu
id="simple-menu"
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={handleClose}
>
{menuItems.map((item, index) => (
<MenuItem
key={index}
disabled={item.disabled}
onClick={() => {
handleClose();
item.onClick(data);
}}
>
{item.label}
</MenuItem>
))}
</Menu>
</>
);
};

View File

@ -5,7 +5,7 @@ import Skeleton from "@mui/material/Skeleton";
export const TableToolbar = styled(Box)(({ theme }) => ({
fontSize: 13,
marginBottom: theme.spacing(1),
marginTop: theme.spacing(0),
marginTop: 0,
height: 36, // The size of a small button
color: theme.palette.text.secondary,
"& strong": { color: theme.palette.text.primary },

View File

@ -1,11 +1,17 @@
import { css } from "@emotion/css";
import { useTheme } from "@emotion/react";
import CircularProgress from "@mui/material/CircularProgress";
import { makeStyles } from "@mui/styles";
import TextField from "@mui/material/TextField";
import Autocomplete from "@mui/material/Autocomplete";
import { User } from "api/typesGenerated";
import type { User } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { AvatarData } from "components/AvatarData/AvatarData";
import { ChangeEvent, ComponentProps, FC, useState } from "react";
import {
type ChangeEvent,
type ComponentProps,
type FC,
useState,
} from "react";
import Box from "@mui/material/Box";
import { useDebouncedFunction } from "hooks/debounce";
import { useQuery } from "react-query";
@ -27,7 +33,7 @@ export const UserAutocomplete: FC<UserAutocompleteProps> = ({
className,
size = "small",
}) => {
const styles = useStyles();
const theme = useTheme();
const [autoComplete, setAutoComplete] = useState<{
value: string;
open: boolean;
@ -101,7 +107,11 @@ export const UserAutocomplete: FC<UserAutocompleteProps> = ({
size={size}
label={label}
placeholder="User email or username"
className={styles.textField}
css={{
"&:not(:has(label))": {
margin: 0,
},
}}
InputProps={{
...params.InputProps,
onChange: debouncedInputOnChange,
@ -119,7 +129,12 @@ export const UserAutocomplete: FC<UserAutocompleteProps> = ({
</>
),
classes: {
root: styles.inputRoot,
root: css`
padding-left: ${theme.spacing(
1.75,
)} !important; // Same padding left as input
gap: ${theme.spacing(0.5)};
`,
},
}}
InputLabelProps={{
@ -130,15 +145,3 @@ export const UserAutocomplete: FC<UserAutocompleteProps> = ({
/>
);
};
export const useStyles = makeStyles((theme) => ({
textField: {
"&:not(:has(label))": {
margin: 0,
},
},
inputRoot: {
paddingLeft: `${theme.spacing(1.75)} !important`, // Same padding left as input
gap: theme.spacing(0.5),
},
}));

View File

@ -37,7 +37,7 @@ export const Welcome: FC<
font-size: ${theme.spacing(4)};
font-weight: 400;
margin: 0;
margin-bottom: ${theme.spacing(2)};
margin-bottom: ${theme.spacing(4)};
margin-top: ${theme.spacing(2)};
line-height: 1.25;

View File

@ -1,11 +1,10 @@
import { makeStyles } from "@mui/styles";
import dayjs from "dayjs";
import { ComponentProps, FC, Fragment } from "react";
import { ProvisionerJobLog } from "api/typesGenerated";
import { type ComponentProps, type FC, Fragment } from "react";
import type { ProvisionerJobLog } from "api/typesGenerated";
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
import { Logs } from "./Logs";
import Box from "@mui/material/Box";
import { combineClasses } from "utils/combineClasses";
import { type Interpolation, type Theme } from "@emotion/react";
const Language = {
seconds: "seconds",
@ -53,7 +52,6 @@ export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({
}) => {
const groupedLogsByStage = groupLogsByStage(logs);
const stages = Object.keys(groupedLogsByStage);
const styles = useStyles();
return (
<Box
@ -79,15 +77,10 @@ export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({
return (
<Fragment key={stage}>
<div
className={combineClasses([
styles.header,
sticky ? styles.sticky : "",
])}
>
<div css={[styles.header, sticky && styles.sticky]}>
<div>{stage}</div>
{shouldDisplayDuration && (
<div className={styles.duration}>
<div css={styles.duration}>
{duration} {Language.seconds}
</div>
)}
@ -100,8 +93,8 @@ export const WorkspaceBuildLogs: FC<WorkspaceBuildLogsProps> = ({
);
};
const useStyles = makeStyles((theme) => ({
header: {
const styles = {
header: (theme) => ({
fontSize: 13,
fontWeight: 600,
padding: theme.spacing(0.5, 3),
@ -119,16 +112,16 @@ const useStyles = makeStyles((theme) => ({
"&:first-child": {
borderRadius: "8px 8px 0 0",
},
},
}),
sticky: {
position: "sticky",
top: 0,
},
duration: {
duration: (theme) => ({
marginLeft: "auto",
color: theme.palette.text.secondary,
fontSize: 12,
},
}));
}),
} satisfies Record<string, Interpolation<Theme>>;

View File

@ -20,7 +20,7 @@ export const Language = {
outdatedLabel: "Outdated",
versionTooltipText:
"This workspace version is outdated and a newer version is available.",
updateVersionLabel: "Update version",
updateVersionLabel: "Update",
};
interface TooltipProps {

View File

@ -1,15 +1,17 @@
import { Workspace } from "api/typesGenerated";
import type { Workspace } from "api/typesGenerated";
import { Pill } from "components/Pill/Pill";
import { FC, PropsWithChildren } from "react";
import { makeStyles } from "@mui/styles";
import { combineClasses } from "utils/combineClasses";
import { type FC, type PropsWithChildren } from "react";
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
import { DormantDeletionText } from "components/WorkspaceDeletion";
import { getDisplayWorkspaceStatus } from "utils/workspace";
import Tooltip, { TooltipProps, tooltipClasses } from "@mui/material/Tooltip";
import Tooltip, {
type TooltipProps,
tooltipClasses,
} from "@mui/material/Tooltip";
import { styled } from "@mui/material/styles";
import Box from "@mui/material/Box";
import ErrorOutline from "@mui/icons-material/ErrorOutline";
import { type Interpolation, type Theme } from "@emotion/react";
export type WorkspaceStatusBadgeProps = {
workspace: Workspace;
@ -56,7 +58,6 @@ export const WorkspaceStatusBadge: FC<
export const WorkspaceStatusText: FC<
PropsWithChildren<WorkspaceStatusBadgeProps>
> = ({ workspace, className }) => {
const styles = useStyles();
const { text, type } = getDisplayWorkspaceStatus(
workspace.latest_build.status,
);
@ -71,11 +72,8 @@ export const WorkspaceStatusText: FC<
<span
role="status"
data-testid="build-status"
className={combineClasses([
className,
styles.root,
styles[`type-${type}`],
])}
className={className}
css={[styles.root, styles[`type-${type}`]]}
>
{text}
</span>
@ -95,27 +93,22 @@ const FailureTooltip = styled(({ className, ...props }: TooltipProps) => (
},
}));
const useStyles = makeStyles((theme) => ({
const styles = {
root: { fontWeight: 600 },
"type-error": {
"type-error": (theme) => ({
color: theme.palette.error.light,
},
"type-warning": {
}),
"type-warning": (theme) => ({
color: theme.palette.warning.light,
},
"type-success": {
}),
"type-success": (theme) => ({
color: theme.palette.success.light,
},
"type-info": {
}),
"type-info": (theme) => ({
color: theme.palette.info.light,
},
"type-undefined": {
}),
"type-undefined": (theme) => ({
color: theme.palette.text.secondary,
},
"type-primary": {
color: theme.palette.text.primary,
},
"type-secondary": {
color: theme.palette.text.secondary,
},
}));
}),
} satisfies Record<string, Interpolation<Theme>>;

View File

@ -1,11 +1,11 @@
import { useTheme, type CSSObject } from "@emotion/react";
import { type MouseEventHandler } from "react";
import { type TableRowProps } from "@mui/material/TableRow";
import { makeStyles } from "@mui/styles";
import { useClickable, type UseClickableResult } from "./useClickable";
type UseClickableTableRowResult = UseClickableResult<HTMLTableRowElement> &
TableRowProps & {
className: string;
css: CSSObject;
hover: true;
onAuxClick: MouseEventHandler<HTMLTableRowElement>;
};
@ -28,12 +28,24 @@ export const useClickableTableRow = ({
onDoubleClick,
onMiddleClick,
}: UseClickableTableRowConfig): UseClickableTableRowResult => {
const styles = useStyles();
const clickableProps = useClickable(onClick);
const theme = useTheme();
return {
...clickableProps,
className: styles.row,
css: {
cursor: "pointer",
"&:focus": {
outline: `1px solid ${theme.palette.secondary.dark}`,
outlineOffset: -1,
},
"&:last-of-type": {
borderBottomLeftRadius: theme.shape.borderRadius,
borderBottomRightRadius: theme.shape.borderRadius,
},
},
hover: true,
onDoubleClick,
onAuxClick: (event) => {
@ -46,19 +58,3 @@ export const useClickableTableRow = ({
},
};
};
const useStyles = makeStyles((theme) => ({
row: {
cursor: "pointer",
"&:focus": {
outline: `1px solid ${theme.palette.secondary.dark}`,
outlineOffset: -1,
},
"&:last-of-type": {
borderBottomLeftRadius: theme.shape.borderRadius,
borderBottomRightRadius: theme.shape.borderRadius,
},
},
}));

View File

@ -1,7 +1,7 @@
import { type Interpolation, type Theme } from "@emotion/react";
import Checkbox from "@mui/material/Checkbox";
import { makeStyles } from "@mui/styles";
import TextField from "@mui/material/TextField";
import {
import type {
ProvisionerJobLog,
Template,
TemplateExample,
@ -9,10 +9,10 @@ import {
VariableValue,
} from "api/typesGenerated";
import { Stack } from "components/Stack/Stack";
import { TemplateUpload, TemplateUploadProps } from "./TemplateUpload";
import { TemplateUpload, type TemplateUploadProps } from "./TemplateUpload";
import { useFormik } from "formik";
import { SelectedTemplate } from "pages/CreateWorkspacePage/SelectedTemplate";
import { FC, useEffect } from "react";
import { type FC, useEffect } from "react";
import {
nameValidator,
getFormHelpers,
@ -43,8 +43,8 @@ import {
} from "pages/TemplateSettingsPage/TemplateSchedulePage/AutostopRequirementHelperText";
import MenuItem from "@mui/material/MenuItem";
import {
TemplateAutostartRequirementDaysValue,
TemplateAutostopRequirementDaysValue,
type TemplateAutostartRequirementDaysValue,
type TemplateAutostopRequirementDaysValue,
} from "utils/schedule";
import {
TemplateScheduleAutostart,
@ -218,7 +218,6 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
allowDisableEveryoneAccess,
allowAutostopRequirement,
} = props;
const styles = useStyles();
const form = useFormik<CreateTemplateData>({
initialValues: getInitialValues({
allowAdvancedScheduling,
@ -338,7 +337,7 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
description="Define when workspaces created from this template automatically stop."
>
<FormFields>
<Stack direction="row" className={styles.ttlFields}>
<Stack direction="row" css={styles.ttlFields}>
<TextField
{...getFieldHelpers(
"default_ttl_hours",
@ -373,7 +372,7 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
</Stack>
{allowAutostopRequirement && (
<Stack direction="row" className={styles.ttlFields}>
<Stack direction="row" css={styles.ttlFields}>
<TextField
{...getFieldHelpers(
"autostop_requirement_days_of_week",
@ -481,7 +480,7 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
<strong>
Allow users to customize autostop duration for workspaces.
</strong>
<span className={styles.optionHelperText}>
<span css={styles.optionHelperText}>
Workspaces will always use the default TTL if this is set.
Regardless of this setting, workspaces can only stay on for
the max TTL.
@ -514,7 +513,7 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
direction="row"
alignItems="center"
spacing={0.5}
className={styles.optionText}
css={styles.optionText}
>
<strong>
Allow users to cancel in-progress workspace jobs
@ -527,7 +526,7 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
</HelpTooltipText>
</HelpTooltip>
</Stack>
<span className={styles.optionHelperText}>
<span css={styles.optionHelperText}>
Depending on your template, canceling builds may leave
workspaces in an unhealthy state. This option isn&apos;t
recommended for most use cases.
@ -552,7 +551,7 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
direction="row"
alignItems="center"
spacing={0.5}
className={styles.optionText}
css={styles.optionText}
>
<strong>Allow everyone to use the template</strong>
@ -569,7 +568,7 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
</HelpTooltipText>
</HelpTooltip>
</Stack>
<span className={styles.optionHelperText}>
<span css={styles.optionHelperText}>
This setting requires an enterprise license for the&nbsp;
<Link href={docs("/admin/rbac")}>
&apos;Template RBAC&apos;
@ -610,14 +609,14 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
{jobError && (
<Stack>
<div className={styles.error}>
<h5 className={styles.errorTitle}>Error during provisioning</h5>
<p className={styles.errorDescription}>
<div css={styles.error}>
<h5 css={styles.errorTitle}>Error during provisioning</h5>
<p css={styles.errorDescription}>
Looks like we found an error during the template provisioning. You
can see the logs bellow.
</p>
<code className={styles.errorDetails}>{jobError}</code>
<code css={styles.errorDetails}>{jobError}</code>
</div>
<WorkspaceBuildLogs logs={logs ?? []} />
@ -690,43 +689,43 @@ const MaxTTLHelperText = (props: { ttl?: number }) => {
);
};
const useStyles = makeStyles((theme) => ({
const styles = {
ttlFields: {
width: "100%",
},
optionText: {
optionText: (theme) => ({
fontSize: theme.spacing(2),
color: theme.palette.text.primary,
},
}),
optionHelperText: {
optionHelperText: (theme) => ({
fontSize: theme.spacing(1.5),
color: theme.palette.text.secondary,
},
}),
error: {
error: (theme) => ({
padding: theme.spacing(3),
borderRadius: theme.spacing(1),
background: theme.palette.background.paper,
border: `1px solid ${theme.palette.error.main}`,
},
}),
errorTitle: {
fontSize: 16,
margin: 0,
},
errorDescription: {
errorDescription: (theme) => ({
margin: 0,
color: theme.palette.text.secondary,
marginTop: theme.spacing(0.5),
},
}),
errorDetails: {
errorDetails: (theme) => ({
display: "block",
marginTop: theme.spacing(1),
color: theme.palette.error.light,
fontSize: theme.spacing(2),
},
}));
}),
} satisfies Record<string, Interpolation<Theme>>;

View File

@ -20,7 +20,8 @@ import {
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers";
import CreateWorkspacePage from "./CreateWorkspacePage";
import { Language } from "utils/formUtils";
import { validationText } from "utils/formUtils";
import { Language } from "./CreateWorkspacePageView";
const nameLabelText = "Workspace Name";
const createWorkspaceText = "Create Workspace";
@ -271,6 +272,28 @@ describe("CreateWorkspacePage", () => {
);
});
});
it("Detects when a workspace is being created with the 'duplicate' mode", async () => {
const params = new URLSearchParams({
mode: "duplicate",
name: MockWorkspace.name,
version: MockWorkspace.template_active_version_id,
});
renderWithAuth(<CreateWorkspacePage />, {
path: "/templates/:template/workspace",
route: `/templates/${MockWorkspace.name}/workspace?${params.toString()}`,
});
const warningMessage = await screen.findByRole("alert");
const nameInput = await screen.findByRole("textbox", {
name: "Workspace Name",
});
expect(warningMessage).toHaveTextContent(Language.duplicationWarning);
expect(nameInput).toHaveValue(`${MockWorkspace.name}-copy`);
});
it("validates against capital letters in workspace name", async () => {
renderCreateWorkspacePage();
await waitForLoaderToBeRemoved();
@ -286,7 +309,7 @@ describe("CreateWorkspacePage", () => {
await userEvent.click(submitButton);
await screen.findByText(
Language.workspaceNameInvalidChars("Workspace Name"),
validationText.workspaceNameInvalidChars("Workspace Name"),
);
// have to use fireEvent b/c userEvent isn't cleaning up properly between tests

View File

@ -30,7 +30,8 @@ import { CreateWSPermissions, createWorkspaceChecks } from "./permissions";
import { paramsUsedToCreateWorkspace } from "utils/workspace";
import { useEffectEvent } from "hooks/hookPolyfills";
type CreateWorkspaceMode = "form" | "auto";
export const createWorkspaceModes = ["form", "auto", "duplicate"] as const;
export type CreateWorkspaceMode = (typeof createWorkspaceModes)[number];
export type ExternalAuthPollingState = "idle" | "polling" | "abandoned";
@ -41,10 +42,9 @@ const CreateWorkspacePage: FC = () => {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const defaultBuildParameters = getDefaultBuildParameters(searchParams);
const mode = (searchParams.get("mode") ?? "form") as CreateWorkspaceMode;
const mode = getWorkspaceMode(searchParams);
const customVersionId = searchParams.get("version") ?? undefined;
const defaultName =
mode === "auto" ? generateUniqueName() : searchParams.get("name") ?? "";
const defaultName = getDefaultName(mode, searchParams);
const queryClient = useQueryClient();
const autoCreateWorkspaceMutation = useMutation(
@ -122,6 +122,7 @@ const CreateWorkspacePage: FC = () => {
<Loader />
) : (
<CreateWorkspacePageView
mode={mode}
defaultName={defaultName}
defaultOwner={me}
defaultBuildParameters={defaultBuildParameters}
@ -220,20 +221,6 @@ const getDefaultBuildParameters = (
return buildValues;
};
export const orderedTemplateParameters = (
templateParameters?: TemplateVersionParameter[],
): TemplateVersionParameter[] => {
if (!templateParameters) {
return [];
}
const immutables = templateParameters.filter(
(parameter) => !parameter.mutable,
);
const mutables = templateParameters.filter((parameter) => parameter.mutable);
return [...immutables, ...mutables];
};
const generateUniqueName = () => {
const numberDictionary = NumberDictionary.generate({ min: 0, max: 99 });
return uniqueNamesGenerator({
@ -245,3 +232,25 @@ const generateUniqueName = () => {
};
export default CreateWorkspacePage;
function getWorkspaceMode(params: URLSearchParams): CreateWorkspaceMode {
const paramMode = params.get("mode");
if (createWorkspaceModes.includes(paramMode as CreateWorkspaceMode)) {
return paramMode as CreateWorkspaceMode;
}
return "form";
}
function getDefaultName(mode: CreateWorkspaceMode, params: URLSearchParams) {
if (mode === "auto") {
return generateUniqueName();
}
const paramsName = params.get("name");
if (mode === "duplicate" && paramsName) {
return `${paramsName}-copy`;
}
return paramsName ?? "";
}

View File

@ -19,6 +19,7 @@ const meta: Meta<typeof CreateWorkspacePageView> = {
template: MockTemplate,
parameters: [],
externalAuth: [],
mode: "form",
permissions: {
createWorkspaceForUser: true,
},

View File

@ -1,8 +1,10 @@
import { css } from "@emotion/css";
import { useTheme, type Interpolation, type Theme } from "@emotion/react";
import TextField from "@mui/material/TextField";
import * as TypesGen from "api/typesGenerated";
import type * as TypesGen from "api/typesGenerated";
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
import { FormikContextType, useFormik } from "formik";
import { FC, useEffect, useState } from "react";
import { type FC, useEffect, useState } from "react";
import {
getFormHelpers,
onChangeTrimmed,
@ -17,7 +19,6 @@ import {
FormFooter,
HorizontalForm,
} from "components/Form/Form";
import { makeStyles } from "@mui/styles";
import {
getInitialRichParameterValues,
useValidationSchemaForRichParameters,
@ -29,11 +30,21 @@ import {
import { ExternalAuth } from "./ExternalAuth";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Stack } from "components/Stack/Stack";
import { type ExternalAuthPollingState } from "./CreateWorkspacePage";
import {
CreateWorkspaceMode,
type ExternalAuthPollingState,
} from "./CreateWorkspacePage";
import { useSearchParams } from "react-router-dom";
import { CreateWSPermissions } from "./permissions";
import { Alert } from "components/Alert/Alert";
export const Language = {
duplicationWarning:
"Duplicating a workspace only copies its parameters. No state from the old workspace is copied over.",
} as const;
export interface CreateWorkspacePageViewProps {
mode: CreateWorkspaceMode;
error: unknown;
defaultName: string;
defaultOwner: TypesGen.User;
@ -54,6 +65,7 @@ export interface CreateWorkspacePageViewProps {
}
export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
mode,
error,
defaultName,
defaultOwner,
@ -69,7 +81,7 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
onSubmit,
onCancel,
}) => {
const styles = useStyles();
const theme = useTheme();
const [owner, setOwner] = useState(defaultOwner);
const { verifyExternalAuth, externalAuthErrors } =
useExternalAuthVerification(externalAuth);
@ -115,6 +127,13 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
<FullPageHorizontalForm title="New workspace" onCancel={onCancel}>
<HorizontalForm onSubmit={form.handleSubmit}>
{Boolean(error) && <ErrorAlert error={error} />}
{mode === "duplicate" && (
<Alert severity="info" dismissible>
{Language.duplicationWarning}
</Alert>
)}
{/* General info */}
<FormSection
title="General"
@ -123,14 +142,14 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
<FormFields>
<SelectedTemplate template={template} />
{versionId && versionId !== template.active_version_id && (
<Stack spacing={1} className={styles.hasDescription}>
<Stack spacing={1} css={styles.hasDescription}>
<TextField
disabled
fullWidth
value={versionId}
label="Version ID"
/>
<span className={styles.description}>
<span css={styles.description}>
This parameter has been preset, and cannot be modified.
</span>
</Stack>
@ -210,7 +229,16 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
/>
<ImmutableTemplateParametersSection
templateParameters={parameters}
classes={{ root: styles.warningSection }}
classes={{
root: css`
border: 1px solid ${theme.palette.warning.light};
border-radius: 8px;
background-color: ${theme.palette.background.paper};
padding: ${theme.spacing(10)};
margin-left: ${theme.spacing(-10)};
margin-right: ${theme.spacing(-10)};
`,
}}
getInputProps={(parameter, index) => {
return {
...getFieldHelpers(
@ -279,23 +307,12 @@ const useExternalAuthVerification = (
};
};
const useStyles = makeStyles((theme) => ({
hasDescription: {
const styles = {
hasDescription: (theme) => ({
paddingBottom: theme.spacing(2),
},
description: {
}),
description: (theme) => ({
fontSize: 13,
color: theme.palette.text.secondary,
},
warningText: {
color: theme.palette.warning.light,
},
warningSection: {
border: `1px solid ${theme.palette.warning.light}`,
borderRadius: 8,
backgroundColor: theme.palette.background.paper,
padding: theme.spacing(10),
marginLeft: theme.spacing(-10),
marginRight: theme.spacing(-10),
},
}));
}),
} satisfies Record<string, Interpolation<Theme>>;

View File

@ -0,0 +1,99 @@
import { waitFor } from "@testing-library/react";
import * as M from "../../testHelpers/entities";
import { type Workspace } from "api/typesGenerated";
import { useWorkspaceDuplication } from "./useWorkspaceDuplication";
import { MockWorkspace } from "testHelpers/entities";
import CreateWorkspacePage from "./CreateWorkspacePage";
import { renderHookWithAuth } from "testHelpers/renderHelpers";
function render(workspace?: Workspace) {
return renderHookWithAuth(
({ workspace }: { workspace?: Workspace }) => {
return useWorkspaceDuplication(workspace);
},
{
initialProps: { workspace },
extraRoutes: [
{
path: "/templates/:template/workspace",
element: <CreateWorkspacePage />,
},
],
},
);
}
type RenderResult = Awaited<ReturnType<typeof render>>;
async function performNavigation(
result: RenderResult["result"],
router: RenderResult["router"],
) {
await waitFor(() => expect(result.current.isDuplicationReady).toBe(true));
result.current.duplicateWorkspace();
return waitFor(() => {
expect(router.state.location.pathname).toEqual(
`/templates/${MockWorkspace.template_name}/workspace`,
);
});
}
describe(`${useWorkspaceDuplication.name}`, () => {
it("Will never be ready when there is no workspace passed in", async () => {
const { result, rerender } = await render(undefined);
expect(result.current.isDuplicationReady).toBe(false);
for (let i = 0; i < 10; i++) {
rerender({ workspace: undefined });
expect(result.current.isDuplicationReady).toBe(false);
}
});
it("Will become ready when workspace is provided and build params are successfully fetched", async () => {
const { result } = await render(MockWorkspace);
expect(result.current.isDuplicationReady).toBe(false);
await waitFor(() => expect(result.current.isDuplicationReady).toBe(true));
});
it("Is able to navigate the user to the workspace creation page", async () => {
const { result, router } = await render(MockWorkspace);
await performNavigation(result, router);
});
test("Navigating populates the URL search params with the workspace's build params", async () => {
const { result, router } = await render(MockWorkspace);
await performNavigation(result, router);
const parsedParams = new URLSearchParams(router.state.location.search);
const mockBuildParams = [
M.MockWorkspaceBuildParameter1,
M.MockWorkspaceBuildParameter2,
M.MockWorkspaceBuildParameter3,
M.MockWorkspaceBuildParameter4,
M.MockWorkspaceBuildParameter5,
];
for (const { name, value } of mockBuildParams) {
const key = `param.${name}`;
expect(parsedParams.get(key)).toEqual(value);
}
});
test("Navigating appends other necessary metadata to the search params", async () => {
const { result, router } = await render(MockWorkspace);
await performNavigation(result, router);
const parsedParams = new URLSearchParams(router.state.location.search);
const extraMetadataEntries = [
["mode", "duplicate"],
["name", MockWorkspace.name],
["version", MockWorkspace.template_active_version_id],
] as const;
for (const [key, value] of extraMetadataEntries) {
expect(parsedParams.get(key)).toBe(value);
}
});
});

View File

@ -0,0 +1,71 @@
import { useNavigate } from "react-router-dom";
import { useQuery } from "react-query";
import { type CreateWorkspaceMode } from "./CreateWorkspacePage";
import {
type Workspace,
type WorkspaceBuildParameter,
} from "api/typesGenerated";
import { workspaceBuildParameters } from "api/queries/workspaceBuilds";
import { useCallback } from "react";
function getDuplicationUrlParams(
workspaceParams: readonly WorkspaceBuildParameter[],
workspace: Workspace,
): URLSearchParams {
// Record type makes sure that every property key added starts with "param.";
// page is also set up to parse params with this prefix for auto mode
const consolidatedParams: Record<`param.${string}`, string> = {};
for (const p of workspaceParams) {
consolidatedParams[`param.${p.name}`] = p.value;
}
return new URLSearchParams({
...consolidatedParams,
mode: "duplicate" satisfies CreateWorkspaceMode,
name: workspace.name,
version: workspace.template_active_version_id,
});
}
/**
* Takes a workspace, and returns out a function that will navigate the user to
* the 'Create Workspace' page, pre-filling the form with as much information
* about the workspace as possible.
*/
export function useWorkspaceDuplication(workspace?: Workspace) {
const navigate = useNavigate();
const buildParametersQuery = useQuery(
workspace !== undefined
? workspaceBuildParameters(workspace.latest_build.id)
: { enabled: false },
);
// Not using useEffectEvent for this, because useEffect isn't really an
// intended use case for this custom hook
const duplicateWorkspace = useCallback(() => {
const buildParams = buildParametersQuery.data;
if (buildParams === undefined || workspace === undefined) {
return;
}
const newUrlParams = getDuplicationUrlParams(buildParams, workspace);
// Necessary for giving modals/popups time to flush their state changes and
// close the popup before actually navigating. MUI does provide the
// disablePortal prop, which also side-steps this issue, but you have to
// remember to put it on any component that calls this function. Better to
// code defensively and have some redundancy in case someone forgets
void Promise.resolve().then(() => {
navigate({
pathname: `/templates/${workspace.template_name}/workspace`,
search: newUrlParams.toString(),
});
});
}, [navigate, workspace, buildParametersQuery.data]);
return {
duplicateWorkspace,
isDuplicationReady: buildParametersQuery.isSuccess,
} as const;
}

View File

@ -1,13 +1,14 @@
import { type Interpolation, type Theme } from "@emotion/react";
import Button from "@mui/material/Button";
import { makeStyles, useTheme } from "@mui/styles";
import { useTheme } from "@mui/styles";
import Skeleton from "@mui/material/Skeleton";
import AddIcon from "@mui/icons-material/AddOutlined";
import RefreshIcon from "@mui/icons-material/Refresh";
import { GetLicensesResponse } from "api/api";
import type { GetLicensesResponse } from "api/api";
import { Header } from "components/DeploySettingsLayout/Header";
import { LicenseCard } from "./LicenseCard";
import { Stack } from "components/Stack/Stack";
import { FC } from "react";
import { type FC } from "react";
import Confetti from "react-confetti";
import { Link } from "react-router-dom";
import useWindowSize from "react-use/lib/useWindowSize";
@ -38,10 +39,8 @@ const LicensesSettingsPageView: FC<Props> = ({
removeLicense,
refreshEntitlements,
}) => {
const styles = useStyles();
const { width, height } = useWindowSize();
const theme = useTheme();
const { width, height } = useWindowSize();
return (
<>
@ -107,13 +106,11 @@ const LicensesSettingsPageView: FC<Props> = ({
)}
{!isLoading && licenses === null && (
<div className={styles.root}>
<div css={styles.root}>
<Stack alignItems="center" spacing={1}>
<Stack alignItems="center" spacing={0.5}>
<span className={styles.title}>
You don&apos;t have any licenses!
</span>
<span className={styles.description}>
<span css={styles.title}>You don&apos;t have any licenses!</span>
<span css={styles.description}>
You&apos;re missing out on high availability, RBAC, quotas, and
much more. Contact{" "}
<MuiLink href="mailto:sales@coder.com">sales</MuiLink> or{" "}
@ -130,12 +127,12 @@ const LicensesSettingsPageView: FC<Props> = ({
);
};
const useStyles = makeStyles((theme) => ({
title: {
const styles = {
title: (theme) => ({
fontSize: theme.spacing(2),
},
}),
root: {
root: (theme) => ({
minHeight: theme.spacing(30),
display: "flex",
alignItems: "center",
@ -143,14 +140,14 @@ const useStyles = makeStyles((theme) => ({
borderRadius: theme.shape.borderRadius,
border: `1px solid ${theme.palette.divider}`,
padding: theme.spacing(6),
},
}),
description: {
description: (theme) => ({
color: theme.palette.text.secondary,
textAlign: "center",
maxWidth: theme.spacing(58),
marginTop: theme.spacing(1),
},
}));
}),
} satisfies Record<string, Interpolation<Theme>>;
export default LicensesSettingsPageView;

View File

@ -20,7 +20,6 @@ import {
PageHeaderTitle,
} from "components/PageHeader/PageHeader";
import { Stack } from "components/Stack/Stack";
import { TableRowMenu } from "components/TableRowMenu/TableRowMenu";
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
import { type FC, useState } from "react";
import { Helmet } from "react-helmet-async";
@ -46,6 +45,12 @@ import Box from "@mui/material/Box";
import { LastSeen } from "components/LastSeen/LastSeen";
import { type Interpolation, type Theme } from "@emotion/react";
import LoadingButton from "@mui/lab/LoadingButton";
import {
MoreMenu,
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
} from "components/MoreMenu/MoreMenu";
export const GroupPage: FC = () => {
const { groupId } = useParams() as { groupId: string };
@ -281,12 +286,12 @@ const GroupMemberRow = (props: {
</TableCell>
<TableCell width="1%">
{canUpdate && (
<TableRowMenu
data={member}
menuItems={[
{
label: "Remove",
onClick: async () => {
<MoreMenu>
<MoreMenuTrigger />
<MoreMenuContent>
<MoreMenuItem
danger
onClick={async () => {
try {
await removeMemberMutation.mutateAsync({
groupId: group.id,
@ -298,11 +303,13 @@ const GroupMemberRow = (props: {
getErrorMessage(error, "Failed to remove member."),
);
}
},
disabled: group.id === group.organization_id,
},
]}
/>
}}
disabled={group.id === group.organization_id}
>
Remove
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
)}
</TableCell>
</TableRow>

View File

@ -1,6 +1,6 @@
import { type Interpolation, type Theme } from "@emotion/react";
import Button from "@mui/material/Button";
import Link from "@mui/material/Link";
import { makeStyles } from "@mui/styles";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
@ -20,10 +20,10 @@ import {
TableRowSkeleton,
} from "components/TableLoader/TableLoader";
import { UserAvatar } from "components/UserAvatar/UserAvatar";
import { FC } from "react";
import { type FC } from "react";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import { Paywall } from "components/Paywall/Paywall";
import { Group } from "api/typesGenerated";
import type { Group } from "api/typesGenerated";
import { GroupAvatar } from "components/GroupAvatar/GroupAvatar";
import { docs } from "utils/docs";
import Skeleton from "@mui/material/Skeleton";
@ -44,7 +44,6 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
const isLoading = Boolean(groups === undefined);
const isEmpty = Boolean(groups && groups.length === 0);
const navigate = useNavigate();
const styles = useStyles();
return (
<>
@ -137,7 +136,7 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
navigate(groupPageLink);
}
}}
className={styles.clickableTableRow}
css={styles.clickableTableRow}
>
<TableCell>
<AvatarData
@ -170,10 +169,8 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
</TableCell>
<TableCell>
<div className={styles.arrowCell}>
<KeyboardArrowRight
className={styles.arrowRight}
/>
<div css={styles.arrowCell}>
<KeyboardArrowRight css={styles.arrowRight} />
</div>
</TableCell>
</TableRow>
@ -210,8 +207,8 @@ const TableLoader = () => {
);
};
const useStyles = makeStyles((theme) => ({
clickableTableRow: {
const styles = {
clickableTableRow: (theme) => ({
cursor: "pointer",
"&:hover td": {
@ -223,17 +220,17 @@ const useStyles = makeStyles((theme) => ({
},
"& .MuiTableCell-root:last-child": {
paddingRight: theme.spacing(2),
paddingRight: `${theme.spacing(2)} !important`,
},
},
arrowRight: {
}),
arrowRight: (theme) => ({
color: theme.palette.text.secondary,
width: 20,
height: 20,
},
}),
arrowCell: {
display: "flex",
},
}));
} satisfies Record<string, Interpolation<Theme>>;
export default GroupsPageView;

View File

@ -1,4 +1,9 @@
import { MockAuthMethods, mockApiError } from "testHelpers/entities";
import {
MockAuthMethodsAll,
MockAuthMethodsExternal,
MockAuthMethodsPasswordOnly,
mockApiError,
} from "testHelpers/entities";
import { LoginPageView } from "./LoginPageView";
import type { Meta, StoryObj } from "@storybook/react";
@ -12,17 +17,37 @@ type Story = StoryObj<typeof LoginPageView>;
export const Example: Story = {
args: {
authMethods: MockAuthMethods,
authMethods: MockAuthMethodsPasswordOnly,
},
};
export const WithExternalAuthMethods: Story = {
args: {
authMethods: MockAuthMethodsExternal,
},
};
export const WithAllAuthMethods: Story = {
args: {
authMethods: MockAuthMethodsAll,
},
};
export const AuthError: Story = {
args: {
error: mockApiError({
message: "User or password is incorrect",
detail: "Please, try again",
message: "Incorrect email or password.",
}),
authMethods: MockAuthMethods,
authMethods: MockAuthMethodsPasswordOnly,
},
};
export const ExternalAuthError: Story = {
args: {
error: mockApiError({
message: "Incorrect email or password.",
}),
authMethods: MockAuthMethodsAll,
},
};
@ -35,6 +60,6 @@ export const LoadingAuthMethods: Story = {
export const SigningIn: Story = {
args: {
isSigningIn: true,
authMethods: MockAuthMethods,
authMethods: MockAuthMethodsPasswordOnly,
},
};

View File

@ -1,10 +1,6 @@
import Box from "@mui/material/Box";
import Checkbox from "@mui/material/Checkbox";
import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import { SignInLayout } from "components/SignInLayout/SignInLayout";
import { Stack } from "components/Stack/Stack";
import { Welcome } from "components/Welcome/Welcome";
import { type FormikContextType, useFormik } from "formik";
import {
getFormHelpers,
@ -14,6 +10,10 @@ import {
import * as Yup from "yup";
import type * as TypesGen from "api/typesGenerated";
import LoadingButton from "@mui/lab/LoadingButton";
import { FormFields, VerticalForm } from "components/Form/Form";
import { CoderIcon } from "components/Icons/CoderIcon";
import Link from "@mui/material/Link";
import { docs } from "utils/docs";
export const Language = {
emailLabel: "Email",
@ -64,10 +64,40 @@ export const SetupPageView: React.FC<SetupPageViewProps> = ({
return (
<SignInLayout>
<Welcome message={Language.welcomeMessage} />
<form onSubmit={form.handleSubmit}>
<Stack>
<header
css={(theme) => ({
textAlign: "center",
marginBottom: theme.spacing(4),
})}
>
<CoderIcon
css={(theme) => ({
color: theme.palette.text.primary,
fontSize: theme.spacing(8),
})}
/>
<h1
css={(theme) => ({
fontWeight: 400,
margin: 0,
marginTop: theme.spacing(2),
})}
>
Welcome to <strong>Coder</strong>
</h1>
<div
css={(theme) => ({
marginTop: theme.spacing(1.5),
color: theme.palette.text.secondary,
})}
>
Let&lsquo;s create your first admin user account
</div>
</header>
<VerticalForm onSubmit={form.handleSubmit}>
<FormFields>
<TextField
autoFocus
{...getFieldHelpers("username")}
onChange={onChangeTrimmed(form)}
autoComplete="username"
@ -89,40 +119,65 @@ export const SetupPageView: React.FC<SetupPageViewProps> = ({
label={Language.passwordLabel}
type="password"
/>
<div css={{ borderRadius: 16 }}>
<Box display="flex">
<div>
<Checkbox
id="trial"
name="trial"
defaultChecked
value={form.values.trial}
onChange={form.handleChange}
data-testid="trial"
/>
</div>
<Box>
<Typography variant="h6" style={{ fontSize: 14 }}>
Start a 30-day free trial of Enterprise
</Typography>
<Typography variant="caption" color="textSecondary">
Get access to high availability, template RBAC, audit logging,
quotas, and more.
</Typography>
</Box>
</Box>
</div>
<label
htmlFor="trial"
css={{
display: "flex",
cursor: "pointer",
alignItems: "flex-start",
gap: 4,
marginTop: -4,
marginBottom: 8,
}}
>
<Checkbox
id="trial"
name="trial"
value={form.values.trial}
onChange={form.handleChange}
data-testid="trial"
size="small"
/>
<div css={{ fontSize: 14, paddingTop: 4 }}>
<span css={{ display: "block", fontWeight: 600 }}>
Start a 30-day free trial of Enterprise
</span>
<span
css={(theme) => ({
display: "block",
fontSize: 13,
color: theme.palette.text.secondary,
lineHeight: "1.6",
})}
>
Get access to high availability, template RBAC, audit logging,
quotas, and more.
</span>
<Link
href={docs("/enterprise")}
target="_blank"
css={{ marginTop: 4, display: "inline-block", fontSize: 13 }}
>
Read more
</Link>
</div>
</label>
<LoadingButton
fullWidth
loading={isLoading}
type="submit"
data-testid="create"
size="large"
variant="contained"
color="primary"
>
{Language.create}
</LoadingButton>
</Stack>
</form>
</FormFields>
</VerticalForm>
</SignInLayout>
);
};

View File

@ -1,5 +1,5 @@
import Button from "@mui/material/Button";
import { makeStyles } from "@mui/styles";
import { useTheme } from "@emotion/react";
import { Loader } from "components/Loader/Loader";
import { Margins } from "components/Margins/Margins";
import { MemoizedMarkdown } from "components/Markdown/Markdown";
@ -8,13 +8,13 @@ import {
PageHeaderSubtitle,
PageHeaderTitle,
} from "components/PageHeader/PageHeader";
import { FC } from "react";
import { type FC } from "react";
import ViewCodeIcon from "@mui/icons-material/OpenInNewOutlined";
import PlusIcon from "@mui/icons-material/AddOutlined";
import { Stack } from "components/Stack/Stack";
import { Link } from "react-router-dom";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { TemplateExample } from "api/typesGenerated";
import type { TemplateExample } from "api/typesGenerated";
export interface StarterTemplatePageViewProps {
starterTemplate?: TemplateExample;
@ -25,7 +25,7 @@ export const StarterTemplatePageView: FC<StarterTemplatePageViewProps> = ({
starterTemplate,
error,
}) => {
const styles = useStyles();
const theme = useTheme();
if (error) {
return (
@ -65,7 +65,19 @@ export const StarterTemplatePageView: FC<StarterTemplatePageViewProps> = ({
}
>
<Stack direction="row" spacing={3} alignItems="center">
<div className={styles.icon}>
<div
css={{
height: theme.spacing(6),
width: theme.spacing(6),
display: "flex",
alignItems: "center",
justifyContent: "center",
"& img": {
width: "100%",
},
}}
>
<img src={starterTemplate.icon} alt="" />
</div>
<div>
@ -77,39 +89,24 @@ export const StarterTemplatePageView: FC<StarterTemplatePageViewProps> = ({
</Stack>
</PageHeader>
<div className={styles.markdownSection} id="readme">
<div className={styles.markdownWrapper}>
<div
css={{
background: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
}}
id="readme"
>
<div
css={{
padding: theme.spacing(5, 5, 8),
maxWidth: 800,
margin: "auto",
}}
>
<MemoizedMarkdown>{starterTemplate.markdown}</MemoizedMarkdown>
</div>
</div>
</Margins>
);
};
export const useStyles = makeStyles((theme) => {
return {
icon: {
height: theme.spacing(6),
width: theme.spacing(6),
display: "flex",
alignItems: "center",
justifyContent: "center",
"& img": {
width: "100%",
},
},
markdownSection: {
background: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
},
markdownWrapper: {
padding: theme.spacing(5, 5, 8),
maxWidth: 800,
margin: "auto",
},
};
});

View File

@ -1,4 +1,4 @@
import { makeStyles } from "@mui/styles";
import { type Interpolation, type Theme } from "@emotion/react";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Loader } from "components/Loader/Loader";
import { Margins } from "components/Margins/Margins";
@ -9,10 +9,9 @@ import {
} from "components/PageHeader/PageHeader";
import { Stack } from "components/Stack/Stack";
import { TemplateExampleCard } from "components/TemplateExampleCard/TemplateExampleCard";
import { FC } from "react";
import { type FC } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { combineClasses } from "utils/combineClasses";
import { StarterTemplatesByTag } from "utils/starterTemplates";
import type { StarterTemplatesByTag } from "utils/starterTemplates";
const getTagLabel = (tag: string) => {
const labelByTag: Record<string, string> = {
@ -40,7 +39,6 @@ export const StarterTemplatesPageView: FC<StarterTemplatesPageViewProps> = ({
error,
}) => {
const [urlParams] = useSearchParams();
const styles = useStyles();
const tags = starterTemplatesByTag
? selectTags(starterTemplatesByTag)
: undefined;
@ -64,16 +62,16 @@ export const StarterTemplatesPageView: FC<StarterTemplatesPageViewProps> = ({
<Stack direction="row" spacing={4}>
{starterTemplatesByTag && tags && (
<Stack className={styles.filter}>
<span className={styles.filterCaption}>Filter</span>
<Stack css={styles.filter}>
<span css={styles.filterCaption}>Filter</span>
{tags.map((tag) => (
<Link
key={tag}
to={`?tag=${tag}`}
className={combineClasses({
[styles.tagLink]: true,
[styles.tagLinkActive]: tag === activeTag,
})}
css={[
styles.tagLink,
tag === activeTag && styles.tagLinkActive,
]}
>
{getTagLabel(tag)} ({starterTemplatesByTag[tag].length})
</Link>
@ -81,7 +79,7 @@ export const StarterTemplatesPageView: FC<StarterTemplatesPageViewProps> = ({
</Stack>
)}
<div className={styles.templates}>
<div css={styles.templates}>
{visibleTemplates &&
visibleTemplates.map((example) => (
<TemplateExampleCard example={example} key={example.id} />
@ -92,21 +90,21 @@ export const StarterTemplatesPageView: FC<StarterTemplatesPageViewProps> = ({
);
};
const useStyles = makeStyles((theme) => ({
filter: {
const styles = {
filter: (theme) => ({
width: theme.spacing(26),
flexShrink: 0,
},
}),
filterCaption: {
filterCaption: (theme) => ({
textTransform: "uppercase",
fontWeight: 600,
fontSize: 12,
color: theme.palette.text.secondary,
letterSpacing: "0.1em",
},
}),
tagLink: {
tagLink: (theme) => ({
color: theme.palette.text.secondary,
textDecoration: "none",
fontSize: 14,
@ -115,18 +113,18 @@ const useStyles = makeStyles((theme) => ({
"&:hover": {
color: theme.palette.text.primary,
},
},
}),
tagLinkActive: {
tagLinkActive: (theme) => ({
color: theme.palette.text.primary,
fontWeight: 600,
},
}),
templates: {
templates: (theme) => ({
flex: "1",
display: "grid",
gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
gap: theme.spacing(2),
gridAutoRows: "min-content",
},
}));
}),
} satisfies Record<string, Interpolation<Theme>>;

View File

@ -1,4 +1,4 @@
import { type FC, useRef, useState } from "react";
import { type FC } from "react";
import { Link as RouterLink, useNavigate } from "react-router-dom";
import { useDeletionDialogState } from "./useDeletionDialogState";
@ -20,17 +20,19 @@ import {
PageHeaderTitle,
PageHeaderSubtitle,
} from "components/PageHeader/PageHeader";
import Button from "@mui/material/Button";
import MoreVertOutlined from "@mui/icons-material/MoreVertOutlined";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import IconButton from "@mui/material/IconButton";
import AddIcon from "@mui/icons-material/AddOutlined";
import SettingsIcon from "@mui/icons-material/SettingsOutlined";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import EditIcon from "@mui/icons-material/EditOutlined";
import CopyIcon from "@mui/icons-material/FileCopyOutlined";
import {
MoreMenu,
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
} from "components/MoreMenu/MoreMenu";
import Divider from "@mui/material/Divider";
type TemplateMenuProps = {
templateName: string;
@ -46,80 +48,54 @@ const TemplateMenu: FC<TemplateMenuProps> = ({
onDelete,
}) => {
const dialogState = useDeletionDialogState(templateId, onDelete);
const menuTriggerRef = useRef<HTMLButtonElement>(null);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const navigate = useNavigate();
const queryText = `template:${templateName}`;
const workspaceCountQuery = useQuery({
...workspaces({ q: queryText }),
select: (res) => res.count,
});
// Returns a function that will execute the action and close the menu
const onMenuItemClick = (actionFn: () => void) => () => {
setIsMenuOpen(false);
actionFn();
};
const safeToDeleteTemplate = workspaceCountQuery.data === 0;
return (
<>
<div>
<IconButton
aria-controls="template-options"
aria-haspopup="true"
onClick={() => setIsMenuOpen(true)}
ref={menuTriggerRef}
arial-label="More options"
>
<MoreVertOutlined />
</IconButton>
<Menu
id="template-options"
anchorEl={menuTriggerRef.current}
open={isMenuOpen}
onClose={() => setIsMenuOpen(false)}
>
<MenuItem
onClick={onMenuItemClick(() =>
navigate(`/templates/${templateName}/settings`),
)}
<MoreMenu>
<MoreMenuTrigger />
<MoreMenuContent>
<MoreMenuItem
onClick={() => {
navigate(`/templates/${templateName}/settings`);
}}
>
<SettingsIcon />
Settings
</MenuItem>
</MoreMenuItem>
<MenuItem
onClick={onMenuItemClick(() =>
<MoreMenuItem
onClick={() => {
navigate(
`/templates/${templateName}/versions/${templateVersion}/edit`,
),
)}
);
}}
>
<EditIcon />
Edit files
</MenuItem>
</MoreMenuItem>
<MenuItem
onClick={onMenuItemClick(() =>
navigate(`/templates/new?fromTemplate=${templateName}`),
)}
<MoreMenuItem
onClick={() => {
navigate(`/templates/new?fromTemplate=${templateName}`);
}}
>
<CopyIcon />
Duplicate&hellip;
</MenuItem>
<MenuItem
onClick={onMenuItemClick(dialogState.openDeleteConfirmation)}
>
</MoreMenuItem>
<Divider />
<MoreMenuItem onClick={dialogState.openDeleteConfirmation} danger>
<DeleteIcon />
Delete&hellip;
</MenuItem>
</Menu>
</div>
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
{safeToDeleteTemplate ? (
<DeleteDialog

View File

@ -1,27 +1,72 @@
import { makeStyles } from "@mui/styles";
import { css } from "@emotion/css";
import {
useTheme,
type CSSObject,
type Interpolation,
type Theme,
} from "@emotion/react";
import ScheduleIcon from "@mui/icons-material/TimerOutlined";
import VariablesIcon from "@mui/icons-material/CodeOutlined";
import { Template } from "api/typesGenerated";
import type { Template } from "api/typesGenerated";
import { Stack } from "components/Stack/Stack";
import { FC, ElementType, PropsWithChildren, ReactNode } from "react";
import {
type FC,
type ElementType,
type PropsWithChildren,
type ReactNode,
} from "react";
import { Link, NavLink } from "react-router-dom";
import { combineClasses } from "utils/combineClasses";
import GeneralIcon from "@mui/icons-material/SettingsOutlined";
import SecurityIcon from "@mui/icons-material/LockOutlined";
import { Avatar } from "components/Avatar/Avatar";
import { combineClasses } from "utils/combineClasses";
const SidebarNavItem: FC<
PropsWithChildren<{ href: string; icon: ReactNode }>
> = ({ children, href, icon }) => {
const styles = useStyles();
const theme = useTheme();
const sidebarNavItemStyles = css`
color: inherit;
display: block;
font-size: 14px;
text-decoration: none;
padding: ${theme.spacing(1.5, 1.5, 1.5, 2)};
border-radius: ${theme.shape.borderRadius / 2}px;
transition: background-color 0.15s ease-in-out;
margin-bottom: 1px;
position: relative;
&:hover {
background-color: ${theme.palette.action.hover};
}
`;
const sidebarNavItemActiveStyles = css`
background-color: ${theme.palette.action.hover};
&:before {
content: "";
display: block;
width: 3px;
height: 100%;
position: absolute;
left: 0;
top: 0;
background-color: ${theme.palette.secondary.dark};
border-top-left-radius: ${theme.shape.borderRadius}px;
border-bottom-left-radius: ${theme.shape.borderRadius}px;
}
`;
return (
<NavLink
end
to={href}
className={({ isActive }) =>
combineClasses([
styles.sidebarNavItem,
isActive ? styles.sidebarNavItemActive : undefined,
sidebarNavItemStyles,
isActive ? sidebarNavItemActiveStyles : undefined,
])
}
>
@ -36,28 +81,21 @@ const SidebarNavItem: FC<
const SidebarNavItemIcon: React.FC<{ icon: ElementType }> = ({
icon: Icon,
}) => {
const styles = useStyles();
return <Icon className={styles.sidebarNavItemIcon} />;
return <Icon css={styles.sidebarNavItemIcon} />;
};
export const Sidebar: React.FC<{ template: Template }> = ({ template }) => {
const styles = useStyles();
return (
<nav className={styles.sidebar}>
<Stack
direction="row"
alignItems="center"
className={styles.templateInfo}
>
<nav css={styles.sidebar}>
<Stack direction="row" alignItems="center" css={styles.templateInfo}>
<Avatar src={template.icon} variant="square" fitImage />
<Stack spacing={0} className={styles.templateData}>
<Link className={styles.name} to={`/templates/${template.name}`}>
<Stack spacing={0} css={styles.templateData}>
<Link css={styles.name} to={`/templates/${template.name}`}>
{template.display_name !== ""
? template.display_name
: template.name}
</Link>
<span className={styles.secondary}>{template.name}</span>
<span css={styles.secondary}>{template.name}</span>
</Stack>
</Stack>
@ -86,65 +124,34 @@ export const Sidebar: React.FC<{ template: Template }> = ({ template }) => {
);
};
const useStyles = makeStyles((theme) => ({
const styles = {
sidebar: {
width: 245,
flexShrink: 0,
},
sidebarNavItem: {
color: "inherit",
display: "block",
fontSize: 14,
textDecoration: "none",
padding: theme.spacing(1.5, 1.5, 1.5, 2),
borderRadius: theme.shape.borderRadius / 2,
transition: "background-color 0.15s ease-in-out",
marginBottom: 1,
position: "relative",
"&:hover": {
backgroundColor: theme.palette.action.hover,
},
},
sidebarNavItemActive: {
backgroundColor: theme.palette.action.hover,
"&:before": {
content: '""',
display: "block",
width: 3,
height: "100%",
position: "absolute",
left: 0,
top: 0,
backgroundColor: theme.palette.secondary.dark,
borderTopLeftRadius: theme.shape.borderRadius,
borderBottomLeftRadius: theme.shape.borderRadius,
},
},
sidebarNavItemIcon: {
sidebarNavItemIcon: (theme) => ({
width: theme.spacing(2),
height: theme.spacing(2),
},
templateInfo: {
...theme.typography.body2,
}),
templateInfo: (theme) => ({
...(theme.typography.body2 as CSSObject),
marginBottom: theme.spacing(2),
},
}),
templateData: {
overflow: "hidden",
},
name: {
name: (theme) => ({
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: theme.palette.text.primary,
textDecoration: "none",
},
secondary: {
}),
secondary: (theme) => ({
color: theme.palette.text.secondary,
fontSize: 12,
overflow: "hidden",
textOverflow: "ellipsis",
},
}));
}),
} satisfies Record<string, Interpolation<Theme>>;

View File

@ -20,7 +20,6 @@ import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
import { EmptyState } from "components/EmptyState/EmptyState";
import { Stack } from "components/Stack/Stack";
import { TableLoader } from "components/TableLoader/TableLoader";
import { TableRowMenu } from "components/TableRowMenu/TableRowMenu";
import {
UserOrGroupAutocomplete,
UserOrGroupAutocompleteValue,
@ -30,6 +29,12 @@ import { GroupAvatar } from "components/GroupAvatar/GroupAvatar";
import { getGroupSubtitle } from "utils/groups";
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
import LoadingButton from "@mui/lab/LoadingButton";
import {
MoreMenu,
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
} from "components/MoreMenu/MoreMenu";
type AddTemplateUserOrGroupProps = {
organizationId: string;
@ -281,16 +286,17 @@ export const TemplatePermissionsPageView: FC<
<TableCell>
{canUpdatePermissions && (
<TableRowMenu
data={group}
menuItems={[
{
label: "Remove",
onClick: () => onRemoveGroup(group),
disabled: false,
},
]}
/>
<MoreMenu>
<MoreMenuTrigger />
<MoreMenuContent>
<MoreMenuItem
danger
onClick={() => onRemoveGroup(group)}
>
Remove
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
)}
</TableCell>
</TableRow>
@ -327,16 +333,17 @@ export const TemplatePermissionsPageView: FC<
<TableCell>
{canUpdatePermissions && (
<TableRowMenu
data={user}
menuItems={[
{
label: "Remove",
onClick: () => onRemoveUser(user),
disabled: false,
},
]}
/>
<MoreMenu>
<MoreMenuTrigger />
<MoreMenuContent>
<MoreMenuItem
danger
onClick={() => onRemoveUser(user)}
>
Remove
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
)}
</TableCell>
</TableRow>

View File

@ -118,7 +118,6 @@ export const ScheduleDialog: FC<PropsWithChildren<ScheduleDialogProps>> = ({
<DialogActions>
<DialogActionButtons
cancelText={cancelText}
confirmDialog
confirmLoading={confirmLoading}
confirmText="Submit"
disabled={disabled}

View File

@ -34,9 +34,9 @@ type Story = StoryObj<typeof TemplateVersionEditor>;
export const Example: Story = {};
export const Logs = {
export const Logs: Story = {
args: {
isBuildingNewVersion: true,
defaultTab: "logs",
buildLogs: MockWorkspaceBuildLogs,
templateVersion: {
...MockTemplateVersion,
@ -47,7 +47,7 @@ export const Logs = {
export const Resources: Story = {
args: {
isBuildingNewVersion: true,
defaultTab: "resources",
buildLogs: MockWorkspaceBuildLogs,
resources: [
MockWorkspaceResource,
@ -60,9 +60,9 @@ export const Resources: Story = {
},
};
export const ManyLogs = {
export const WithError = {
args: {
isBuildingNewVersion: true,
defaultTab: "logs",
templateVersion: {
...MockTemplateVersion,
job: {

View File

@ -49,10 +49,10 @@ import AlertTitle from "@mui/material/AlertTitle";
import { DashboardFullPage } from "components/Dashboard/DashboardLayout";
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
type Tab = "logs" | "resources" | undefined; // Undefined is to hide the tab
export interface TemplateVersionEditorProps {
template: Template;
templateVersion: TemplateVersion;
isBuildingNewVersion: boolean;
defaultFileTree: FileTree;
buildLogs?: ProvisionerJobLog[];
resources?: WorkspaceResource[];
@ -71,6 +71,7 @@ export interface TemplateVersionEditorProps {
missingVariables?: TemplateVersionVariable[];
onSubmitMissingVariableValues: (values: VariableValue[]) => void;
onCancelSubmitMissingVariableValues: () => void;
defaultTab?: Tab;
}
const topbarHeight = 80;
@ -92,7 +93,6 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
disableUpdate,
template,
templateVersion,
isBuildingNewVersion,
defaultFileTree,
onPreview,
onPublish,
@ -109,11 +109,10 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
missingVariables,
onSubmitMissingVariableValues,
onCancelSubmitMissingVariableValues,
defaultTab,
}) => {
const theme = useTheme();
// If resources are provided, show them by default!
// This is for Storybook!
const [selectedTab, setSelectedTab] = useState(() => (resources ? 1 : 0));
const [selectedTab, setSelectedTab] = useState<Tab>(defaultTab);
const [fileTree, setFileTree] = useState(defaultFileTree);
const [createFileOpen, setCreateFileOpen] = useState(false);
const [deleteFileOpen, setDeleteFileOpen] = useState<string>();
@ -125,8 +124,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
const triggerPreview = useCallback(() => {
onPreview(fileTree);
// Switch to the build log!
setSelectedTab(0);
setSelectedTab("logs");
}, [fileTree, onPreview]);
// Stop ctrl+s from saving files and make ctrl+enter trigger a preview.
@ -159,11 +157,12 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
previousVersion.current = templateVersion;
return;
}
if (
["running", "pending"].includes(previousVersion.current.job.status) &&
templateVersion.job.status === "succeeded"
) {
setSelectedTab(1);
setSelectedTab("resources");
setDirty(false);
}
previousVersion.current = templateVersion;
@ -218,7 +217,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
)}
<div css={styles.topbarSides}>
{isBuildingNewVersion && (
{buildLogs && (
<TemplateVersionStatusBadge version={templateVersion} />
)}
@ -358,9 +357,9 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
<div css={styles.tabs}>
<button
css={styles.tab}
className={selectedTab === 0 ? "active" : ""}
className={selectedTab === "logs" ? "active" : ""}
onClick={() => {
setSelectedTab(0);
setSelectedTab("logs");
}}
>
{templateVersion.job.status !== "succeeded" ? (
@ -374,9 +373,9 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
{!disableUpdate && (
<button
css={styles.tab}
className={selectedTab === 1 ? "active" : ""}
className={selectedTab === "resources" ? "active" : ""}
onClick={() => {
setSelectedTab(1);
setSelectedTab("resources");
}}
>
<PreviewIcon />
@ -389,7 +388,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
css={[
styles.panel,
{
display: selectedTab !== 0 ? "none" : "flex",
display: selectedTab !== "logs" ? "none" : "flex",
flexDirection: "column",
},
]}
@ -427,7 +426,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
styles.panel,
{
paddingBottom: theme.spacing(2),
display: selectedTab !== 1 ? "none" : undefined,
display: selectedTab !== "resources" ? "none" : undefined,
},
]}
>

View File

@ -4,6 +4,7 @@ import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import * as api from "api/api";
import {
MockTemplate,
MockTemplateVersion,
MockWorkspaceBuildLogs,
} from "testHelpers/entities";
@ -17,9 +18,10 @@ jest.mock("components/TemplateResourcesTable/TemplateResourcesTable", () => {
};
});
test("Use custom name, message and set it as active when publishing", async () => {
const user = userEvent.setup();
const renderTemplateEditorPage = () => {
renderWithAuth(<TemplateVersionEditorPage />, {
route: `/templates/${MockTemplate.name}/versions/${MockTemplateVersion.name}/edit`,
path: "/templates/:template/versions/:version/edit",
extraRoutes: [
{
path: "/templates/:templateId",
@ -27,16 +29,26 @@ test("Use custom name, message and set it as active when publishing", async () =
},
],
});
};
test("Use custom name, message and set it as active when publishing", async () => {
const user = userEvent.setup();
renderTemplateEditorPage();
const topbar = await screen.findByTestId("topbar");
// Build Template
jest.spyOn(api, "uploadFile").mockResolvedValueOnce({ hash: "hash" });
const newTemplateVersion = {
...MockTemplateVersion,
id: "new-version-id",
name: "new-version",
};
jest
.spyOn(api, "createTemplateVersion")
.mockResolvedValueOnce(MockTemplateVersion);
.mockResolvedValue(newTemplateVersion);
jest
.spyOn(api, "getTemplateVersion")
.mockResolvedValue({ ...MockTemplateVersion, id: "new-version-id" });
.spyOn(api, "getTemplateVersionByName")
.mockResolvedValue(newTemplateVersion);
jest
.spyOn(api, "watchBuildLogsByTemplateVersionId")
.mockImplementation((_, options) => {
@ -52,7 +64,7 @@ test("Use custom name, message and set it as active when publishing", async () =
// Publish
const patchTemplateVersion = jest
.spyOn(api, "patchTemplateVersion")
.mockResolvedValue(MockTemplateVersion);
.mockResolvedValue(newTemplateVersion);
const updateActiveTemplateVersion = jest
.spyOn(api, "updateActiveTemplateVersion")
.mockResolvedValue({ message: "" });
@ -84,24 +96,22 @@ test("Use custom name, message and set it as active when publishing", async () =
test("Do not mark as active if promote is not checked", async () => {
const user = userEvent.setup();
renderWithAuth(<TemplateVersionEditorPage />, {
extraRoutes: [
{
path: "/templates/:templateId",
element: <div />,
},
],
});
renderTemplateEditorPage();
const topbar = await screen.findByTestId("topbar");
// Build Template
jest.spyOn(api, "uploadFile").mockResolvedValueOnce({ hash: "hash" });
const newTemplateVersion = {
...MockTemplateVersion,
id: "new-version-id",
name: "new-version",
};
jest
.spyOn(api, "createTemplateVersion")
.mockResolvedValueOnce(MockTemplateVersion);
.mockResolvedValue(newTemplateVersion);
jest
.spyOn(api, "getTemplateVersion")
.mockResolvedValue({ ...MockTemplateVersion, id: "new-version-id" });
.spyOn(api, "getTemplateVersionByName")
.mockResolvedValue(newTemplateVersion);
jest
.spyOn(api, "watchBuildLogsByTemplateVersionId")
.mockImplementation((_, options) => {
@ -117,7 +127,7 @@ test("Do not mark as active if promote is not checked", async () => {
// Publish
const patchTemplateVersion = jest
.spyOn(api, "patchTemplateVersion")
.mockResolvedValue(MockTemplateVersion);
.mockResolvedValue(newTemplateVersion);
const updateActiveTemplateVersion = jest
.spyOn(api, "updateActiveTemplateVersion")
.mockResolvedValue({ message: "" });
@ -146,30 +156,27 @@ test("Do not mark as active if promote is not checked", async () => {
});
test("Patch request is not send when there are no changes", async () => {
const MockTemplateVersionWithEmptyMessage = {
const newTemplateVersion = {
...MockTemplateVersion,
id: "new-version-id",
name: "new-version",
};
const MockTemplateVersionWithEmptyMessage = {
...newTemplateVersion,
message: "",
};
const user = userEvent.setup();
renderWithAuth(<TemplateVersionEditorPage />, {
extraRoutes: [
{
path: "/templates/:templateId",
element: <div />,
},
],
});
renderTemplateEditorPage();
const topbar = await screen.findByTestId("topbar");
// Build Template
jest.spyOn(api, "uploadFile").mockResolvedValueOnce({ hash: "hash" });
jest
.spyOn(api, "createTemplateVersion")
.mockResolvedValueOnce(MockTemplateVersionWithEmptyMessage);
jest.spyOn(api, "getTemplateVersion").mockResolvedValue({
...MockTemplateVersionWithEmptyMessage,
id: "new-version-id",
});
.mockResolvedValue(MockTemplateVersionWithEmptyMessage);
jest
.spyOn(api, "getTemplateVersionByName")
.mockResolvedValue(MockTemplateVersionWithEmptyMessage);
jest
.spyOn(api, "watchBuildLogsByTemplateVersionId")
.mockImplementation((_, options) => {

View File

@ -1,12 +1,35 @@
import { useMachine } from "@xstate/react";
import { TemplateVersionEditor } from "./TemplateVersionEditor";
import { useOrganizationId } from "hooks/useOrganizationId";
import { FC } from "react";
import { FC, useEffect, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useNavigate, useParams } from "react-router-dom";
import { pageTitle } from "utils/page";
import { templateVersionEditorMachine } from "xServices/templateVersionEditor/templateVersionEditorXService";
import { useTemplateVersionData } from "./data";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
createTemplateVersion,
resources,
templateByName,
templateVersionByName,
templateVersionVariables,
} from "api/queries/templates";
import { file, uploadFile } from "api/queries/files";
import { TarFileTypeCodes, TarReader, TarWriter } from "utils/tar";
import { FileTree, traverse } from "utils/filetree";
import {
createTemplateVersionFileTree,
isAllowedFile,
} from "utils/templateVersion";
import {
patchTemplateVersion,
updateActiveTemplateVersion,
watchBuildLogsByTemplateVersionId,
} from "api/api";
import {
PatchTemplateVersionRequest,
ProvisionerJobLog,
TemplateVersion,
} from "api/typesGenerated";
import { displayError } from "components/GlobalSnackbar/utils";
type Params = {
version: string;
@ -14,25 +37,69 @@ type Params = {
};
export const TemplateVersionEditorPage: FC = () => {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { version: versionName, template: templateName } =
const { version: initialVersionName, template: templateName } =
useParams() as Params;
const orgId = useOrganizationId();
const [editorState, sendEvent] = useMachine(templateVersionEditorMachine, {
context: { orgId },
});
const { isSuccess, data } = useTemplateVersionData(
{
orgId,
templateName,
versionName,
},
{
onSuccess(data) {
sendEvent({ type: "INITIALIZE", tarReader: data.tarReader });
},
},
const [currentVersionName, setCurrentVersionName] =
useState(initialVersionName);
const templateQuery = useQuery(templateByName(orgId, templateName));
const templateVersionOptions = templateVersionByName(
orgId,
templateName,
currentVersionName,
);
const templateVersionQuery = useQuery({
...templateVersionOptions,
keepPreviousData: true,
});
const uploadFileMutation = useMutation(uploadFile());
const createTemplateVersionMutation = useMutation(
createTemplateVersion(orgId),
);
const resourcesQuery = useQuery({
...resources(templateVersionQuery.data?.id ?? ""),
enabled: templateVersionQuery.data?.job.status === "succeeded",
});
const { logs, setLogs } = useVersionLogs(templateVersionQuery.data, {
onDone: templateVersionQuery.refetch,
});
const { fileTree, tarFile } = useFileTree(templateVersionQuery.data);
const {
missingVariables,
setIsMissingVariablesDialogOpen,
isMissingVariablesDialogOpen,
} = useMissingVariables(templateVersionQuery.data);
// Handle template publishing
const [isPublishingDialogOpen, setIsPublishingDialogOpen] = useState(false);
const publishVersionMutation = useMutation({
mutationFn: publishVersion,
});
const [lastSuccessfulPublishedVersion, setLastSuccessfulPublishedVersion] =
useState<TemplateVersion>();
// Optimistically update the template version data job status to make the
// build action feels faster
const onBuildStart = () => {
setLogs([]);
queryClient.setQueryData(templateVersionOptions.queryKey, () => {
return {
...templateVersionQuery.data,
job: {
...templateVersionQuery.data?.job,
status: "pending",
},
};
});
};
const onBuildEnds = (newVersion: TemplateVersion) => {
setCurrentVersionName(newVersion.name);
queryClient.setQueryData(templateVersionOptions.queryKey, newVersion);
};
return (
<>
@ -40,45 +107,54 @@ export const TemplateVersionEditorPage: FC = () => {
<title>{pageTitle(`${templateName} · Template Editor`)}</title>
</Helmet>
{isSuccess && (
{templateQuery.data && templateVersionQuery.data && fileTree && (
<TemplateVersionEditor
template={data.template}
templateVersion={editorState.context.version || data.version}
isBuildingNewVersion={Boolean(editorState.context.version)}
defaultFileTree={data.fileTree}
onPreview={(fileTree) => {
sendEvent({
type: "CREATE_VERSION",
fileTree,
templateId: data.template.id,
template={templateQuery.data}
templateVersion={templateVersionQuery.data}
defaultFileTree={fileTree}
onPreview={async (newFileTree) => {
if (!tarFile) {
return;
}
onBuildStart();
const newVersionFile = await generateVersionFiles(
tarFile,
newFileTree,
);
const serverFile = await uploadFileMutation.mutateAsync(
newVersionFile,
);
const newVersion = await createTemplateVersionMutation.mutateAsync({
provisioner: "terraform",
storage_method: "file",
tags: {},
template_id: templateQuery.data.id,
file_id: serverFile.hash,
});
onBuildEnds(newVersion);
}}
onPublish={() => {
sendEvent({
type: "PUBLISH",
});
setIsPublishingDialogOpen(true);
}}
onCancelPublish={() => {
sendEvent({
type: "CANCEL_PUBLISH",
});
setIsPublishingDialogOpen(false);
}}
onConfirmPublish={(data) => {
sendEvent({
type: "CONFIRM_PUBLISH",
...data,
onConfirmPublish={async ({ isActiveVersion, ...data }) => {
await publishVersionMutation.mutateAsync({
isActiveVersion,
data,
version: templateVersionQuery.data,
});
setIsPublishingDialogOpen(false);
setLastSuccessfulPublishedVersion(templateVersionQuery.data);
}}
isAskingPublishParameters={editorState.matches(
"askPublishParameters",
)}
isPublishing={editorState.matches("publishingVersion")}
publishingError={editorState.context.publishingError}
publishedVersion={editorState.context.lastSuccessfulPublishedVersion}
isAskingPublishParameters={isPublishingDialogOpen}
isPublishing={publishVersionMutation.isLoading}
publishingError={publishVersionMutation.error}
publishedVersion={lastSuccessfulPublishedVersion}
onCreateWorkspace={() => {
const params = new URLSearchParams();
const publishedVersion =
editorState.context.lastSuccessfulPublishedVersion;
const publishedVersion = lastSuccessfulPublishedVersion;
if (publishedVersion) {
params.set("version", publishedVersion.id);
}
@ -86,25 +162,40 @@ export const TemplateVersionEditorPage: FC = () => {
`/templates/${templateName}/workspace?${params.toString()}`,
);
}}
disablePreview={editorState.hasTag("loading")}
disableUpdate={
editorState.hasTag("loading") ||
editorState.context.version?.job.status !== "succeeded"
disablePreview={
templateVersionQuery.data.job.status === "running" ||
templateVersionQuery.data.job.status === "pending" ||
createTemplateVersionMutation.isLoading ||
uploadFileMutation.isLoading
}
resources={editorState.context.resources}
buildLogs={editorState.context.buildLogs}
isPromptingMissingVariables={editorState.matches("promptVariables")}
missingVariables={editorState.context.missingVariables}
onSubmitMissingVariableValues={(values) => {
sendEvent({
type: "SET_MISSING_VARIABLE_VALUES",
values,
disableUpdate={
templateVersionQuery.data.job.status !== "succeeded" ||
templateVersionQuery.data.name === initialVersionName ||
templateVersionQuery.data.name ===
lastSuccessfulPublishedVersion?.name
}
resources={resourcesQuery.data}
buildLogs={logs}
isPromptingMissingVariables={isMissingVariablesDialogOpen}
missingVariables={missingVariables}
onSubmitMissingVariableValues={async (values) => {
if (!uploadFileMutation.data) {
return;
}
onBuildStart();
const newVersion = await createTemplateVersionMutation.mutateAsync({
provisioner: "terraform",
storage_method: "file",
tags: {},
template_id: templateQuery.data.id,
file_id: uploadFileMutation.data.hash,
user_variable_values: values,
});
onBuildEnds(newVersion);
setIsMissingVariablesDialogOpen(false);
}}
onCancelSubmitMissingVariableValues={() => {
sendEvent({
type: "CANCEL_MISSING_VARIABLE_VALUES",
});
setIsMissingVariablesDialogOpen(false);
}}
/>
)}
@ -112,4 +203,163 @@ export const TemplateVersionEditorPage: FC = () => {
);
};
const useFileTree = (templateVersion: TemplateVersion | undefined) => {
const fileQuery = useQuery({
...file(templateVersion?.job.file_id ?? ""),
enabled: templateVersion !== undefined,
});
const [state, setState] = useState<{
fileTree?: FileTree;
tarFile?: TarReader;
}>({
fileTree: undefined,
tarFile: undefined,
});
useEffect(() => {
const initializeFileTree = async (file: ArrayBuffer) => {
const tarFile = new TarReader();
await tarFile.readFile(file);
const fileTree = await createTemplateVersionFileTree(tarFile);
setState({ fileTree, tarFile });
};
if (fileQuery.data) {
initializeFileTree(fileQuery.data).catch(() => {
displayError("Error on initializing the editor");
});
}
}, [fileQuery.data]);
return state;
};
const useVersionLogs = (
templateVersion: TemplateVersion | undefined,
options: { onDone: () => Promise<unknown> },
) => {
const [logs, setLogs] = useState<ProvisionerJobLog[]>();
const templateVersionId = templateVersion?.id;
const refetchTemplateVersion = options.onDone;
const templateVersionStatus = templateVersion?.job.status;
useEffect(() => {
if (!templateVersionId || !templateVersionStatus) {
return;
}
if (templateVersionStatus !== "running") {
return;
}
const socket = watchBuildLogsByTemplateVersionId(templateVersionId, {
onMessage: (log) => {
setLogs((logs) => (logs ? [...logs, log] : [log]));
},
onDone: refetchTemplateVersion,
onError: (error) => {
console.error(error);
},
});
return () => {
socket.close();
};
}, [refetchTemplateVersion, templateVersionId, templateVersionStatus]);
return {
logs,
setLogs,
};
};
const useMissingVariables = (templateVersion: TemplateVersion | undefined) => {
const { data: missingVariables } = useQuery({
...templateVersionVariables(templateVersion?.id ?? ""),
enabled: templateVersion?.job.error_code === "REQUIRED_TEMPLATE_VARIABLES",
});
const [isMissingVariablesDialogOpen, setIsMissingVariablesDialogOpen] =
useState(false);
useEffect(() => {
if (missingVariables) {
setIsMissingVariablesDialogOpen(true);
}
}, [missingVariables]);
return {
missingVariables,
isMissingVariablesDialogOpen,
setIsMissingVariablesDialogOpen,
};
};
const generateVersionFiles = async (
tarReader: TarReader,
fileTree: FileTree,
) => {
const tar = new TarWriter();
// Add previous non editable files
for (const file of tarReader.fileInfo) {
if (!isAllowedFile(file.name)) {
if (file.type === TarFileTypeCodes.Dir) {
tar.addFolder(file.name, {
mode: file.mode, // https://github.com/beatgammit/tar-js/blob/master/lib/tar.js#L42
mtime: file.mtime,
user: file.user,
group: file.group,
});
} else {
tar.addFile(file.name, tarReader.getTextFile(file.name) as string, {
mode: file.mode, // https://github.com/beatgammit/tar-js/blob/master/lib/tar.js#L42
mtime: file.mtime,
user: file.user,
group: file.group,
});
}
}
}
// Add the editable files
traverse(fileTree, (content, _filename, fullPath) => {
// When a file is deleted. Don't add it to the tar.
if (content === undefined) {
return;
}
if (typeof content === "string") {
tar.addFile(fullPath, content);
return;
}
tar.addFolder(fullPath);
});
const blob = (await tar.write()) as Blob;
return new File([blob], "template.tar");
};
const publishVersion = async (options: {
version: TemplateVersion;
data: PatchTemplateVersionRequest;
isActiveVersion: boolean;
}) => {
const { version, data, isActiveVersion } = options;
const haveChanges =
data.name !== version.name || data.message !== version.message;
const publishActions: Promise<unknown>[] = [];
if (haveChanges) {
publishActions.push(patchTemplateVersion(version.id, data));
}
if (isActiveVersion) {
publishActions.push(
updateActiveTemplateVersion(version.template_id!, {
id: version.id,
}),
);
}
return Promise.all(publishActions);
};
export default TemplateVersionEditorPage;

View File

@ -1,47 +0,0 @@
import { useQuery, UseQueryOptions } from "react-query";
import { getFile, getTemplateByName, getTemplateVersionByName } from "api/api";
import { TarReader } from "utils/tar";
import { createTemplateVersionFileTree } from "utils/templateVersion";
const getTemplateVersionData = async (
orgId: string,
templateName: string,
versionName: string,
) => {
const [template, version] = await Promise.all([
getTemplateByName(orgId, templateName),
getTemplateVersionByName(orgId, templateName, versionName),
]);
const tarFile = await getFile(version.job.file_id);
const tarReader = new TarReader();
await tarReader.readFile(tarFile);
const fileTree = await createTemplateVersionFileTree(tarReader);
return {
template,
version,
fileTree,
tarReader,
};
};
type GetTemplateVersionResponse = Awaited<
ReturnType<typeof getTemplateVersionData>
>;
type UseTemplateVersionDataParams = {
orgId: string;
templateName: string;
versionName: string;
};
export const useTemplateVersionData = (
{ templateName, versionName, orgId }: UseTemplateVersionDataParams,
options?: UseQueryOptions<GetTemplateVersionResponse>,
) => {
return useQuery({
queryKey: ["templateVersion", templateName, versionName],
queryFn: () => getTemplateVersionData(orgId, templateName, versionName),
...options,
});
};

View File

@ -1,5 +1,5 @@
import { type Interpolation, type Theme } from "@emotion/react";
import Button from "@mui/material/Button";
import { makeStyles } from "@mui/styles";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
@ -7,7 +7,7 @@ import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import AddIcon from "@mui/icons-material/AddOutlined";
import { FC } from "react";
import { type FC } from "react";
import { useNavigate, Link as RouterLink } from "react-router-dom";
import { createDayString } from "utils/createDayString";
import {
@ -35,8 +35,7 @@ import {
} from "components/HelpTooltip/HelpTooltip";
import { EmptyTemplates } from "./EmptyTemplates";
import { useClickableTableRow } from "hooks/useClickableTableRow";
import { Template, TemplateExample } from "api/typesGenerated";
import { combineClasses } from "utils/combineClasses";
import type { Template, TemplateExample } from "api/typesGenerated";
import { colors } from "theme/colors";
import ArrowForwardOutlined from "@mui/icons-material/ArrowForwardOutlined";
import { Avatar } from "components/Avatar/Avatar";
@ -80,17 +79,17 @@ const TemplateRow: FC<{ template: Template }> = ({ template }) => {
const templatePageLink = `/templates/${template.name}`;
const hasIcon = template.icon && template.icon !== "";
const navigate = useNavigate();
const styles = useStyles();
const { className: clickableClassName, ...clickableRow } =
useClickableTableRow({ onClick: () => navigate(templatePageLink) });
const { css: clickableCss, ...clickableRow } = useClickableTableRow({
onClick: () => navigate(templatePageLink),
});
return (
<TableRow
key={template.id}
data-testid={`template-${template.id}`}
{...clickableRow}
className={combineClasses([clickableClassName, styles.tableRow])}
css={[clickableCss, styles.tableRow]}
>
<TableCell>
<AvatarData
@ -106,22 +105,23 @@ const TemplateRow: FC<{ template: Template }> = ({ template }) => {
/>
</TableCell>
<TableCell className={styles.secondary}>
<TableCell css={styles.secondary}>
{Language.developerCount(template.active_user_count)}
</TableCell>
<TableCell className={styles.secondary}>
<TableCell css={styles.secondary}>
{formatTemplateBuildTime(template.build_time_stats.start.P50)}
</TableCell>
<TableCell data-chromatic="ignore" className={styles.secondary}>
<TableCell data-chromatic="ignore" css={styles.secondary}>
{createDayString(template.updated_at)}
</TableCell>
<TableCell className={styles.actionCell}>
<TableCell css={styles.actionCell}>
<Button
size="small"
className={styles.actionButton}
css={styles.actionButton}
className="actionButton"
startIcon={<ArrowForwardOutlined />}
title={`Create a workspace using the ${template.display_name} template`}
onClick={(e) => {
@ -247,7 +247,7 @@ const TableLoader = () => {
);
};
const useStyles = makeStyles((theme) => ({
const styles = {
templateIconWrapper: {
// Same size then the avatar component
width: 36,
@ -261,20 +261,20 @@ const useStyles = makeStyles((theme) => ({
actionCell: {
whiteSpace: "nowrap",
},
secondary: {
secondary: (theme) => ({
color: theme.palette.text.secondary,
},
tableRow: {
"&:hover $actionButton": {
}),
tableRow: (theme) => ({
"&:hover .actionButton": {
color: theme.palette.text.primary,
borderColor: colors.gray[11],
"&:hover": {
borderColor: theme.palette.text.primary,
},
},
},
actionButton: {
}),
actionButton: (theme) => ({
color: theme.palette.text.secondary,
transition: "none",
},
}));
}),
} satisfies Record<string, Interpolation<Theme>>;

View File

@ -1,5 +1,5 @@
import { makeStyles, useTheme } from "@mui/styles";
import { FC, useCallback, useEffect, useRef, useState } from "react";
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
import { type FC, useCallback, useEffect, useRef, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { colors } from "theme/colors";
@ -16,7 +16,7 @@ import { pageTitle } from "utils/page";
import { useProxy } from "contexts/ProxyContext";
import Box from "@mui/material/Box";
import { useDashboard } from "components/Dashboard/DashboardProvider";
import { Region } from "api/typesGenerated";
import type { Region } from "api/typesGenerated";
import { getLatencyColor } from "utils/latency";
import { ProxyStatusLatency } from "components/ProxyStatusLatency/ProxyStatusLatency";
import { openMaybePortForwardedURL } from "utils/portForward";
@ -45,7 +45,6 @@ export const Language = {
const TerminalPage: FC = () => {
const navigate = useNavigate();
const styles = useStyles();
const { proxy } = useProxy();
const params = useParams() as { username: string; workspace: string };
const username = params.username.replace("@", "");
@ -316,11 +315,7 @@ const TerminalPage: FC = () => {
{lifecycleState === "ready" &&
prevLifecycleState.current === "starting" && <LoadedScriptsAlert />}
{terminalState === "disconnected" && <DisconnectedAlert />}
<div
className={styles.terminal}
ref={xtermRef}
data-testid="terminal"
/>
<div css={styles.terminal} ref={xtermRef} data-testid="terminal" />
{dashboard.experiments.includes("moons") &&
selectedProxy &&
latency && (
@ -426,35 +421,8 @@ const BottomBar = ({ proxy, latency }: { proxy: Region; latency?: number }) => {
);
};
const useStyles = makeStyles((theme) => ({
overlay: {
position: "absolute",
pointerEvents: "none",
top: 0,
left: 0,
bottom: 0,
right: 0,
zIndex: 1,
alignItems: "center",
justifyContent: "center",
display: "flex",
color: "white",
fontSize: 16,
backgroundColor: "rgba(0, 0, 0, 0.6)",
backdropFilter: "blur(4px)",
"&.connected": {
opacity: 0,
},
},
overlayText: {
fontSize: 16,
fontWeight: 600,
},
overlaySubtext: {
fontSize: 14,
color: theme.palette.text.secondary,
},
terminal: {
const styles = {
terminal: (theme) => ({
width: "100vw",
overflow: "hidden",
backgroundColor: theme.palette.background.paper,
@ -480,36 +448,7 @@ const useStyles = makeStyles((theme) => ({
minHeight: 20,
backgroundColor: "rgba(255, 255, 255, 0.18)",
},
},
alert: {
display: "flex",
background: theme.palette.background.paperLight,
alignItems: "center",
padding: theme.spacing(2),
gap: theme.spacing(2),
borderBottom: `1px solid ${theme.palette.divider}`,
...theme.typography.body2,
},
alertIcon: {
color: theme.palette.warning.light,
fontSize: theme.spacing(3),
},
alertError: {
"& $alertIcon": {
color: theme.palette.error.light,
},
},
alertTitle: {
fontWeight: 600,
color: theme.palette.text.primary,
},
alertMessage: {
fontSize: 14,
color: theme.palette.text.secondary,
},
alertActions: {
marginLeft: "auto",
},
}));
}),
} satisfies Record<string, Interpolation<Theme>>;
export default TerminalPage;

View File

@ -6,10 +6,7 @@ import {
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers";
import { SecurityPage } from "./SecurityPage";
import {
MockAuthMethodsWithPasswordType,
mockApiError,
} from "testHelpers/entities";
import { MockAuthMethodsAll, mockApiError } from "testHelpers/entities";
import userEvent from "@testing-library/user-event";
import * as SSO from "./SingleSignOnSection";
import { OAuthConversionResponse } from "api/typesGenerated";
@ -40,9 +37,7 @@ const fillAndSubmitSecurityForm = () => {
};
beforeEach(() => {
jest
.spyOn(API, "getAuthMethods")
.mockResolvedValue(MockAuthMethodsWithPasswordType);
jest.spyOn(API, "getAuthMethods").mockResolvedValue(MockAuthMethodsAll);
jest.spyOn(API, "getUserLoginType").mockResolvedValue({
login_type: "password",
});

View File

@ -2,8 +2,8 @@ import type { Meta, StoryObj } from "@storybook/react";
import { SecurityPageView } from "./SecurityPage";
import { action } from "@storybook/addon-actions";
import {
MockAuthMethods,
MockAuthMethodsWithPasswordType,
MockAuthMethodsPasswordOnly,
MockAuthMethodsAll,
} from "testHelpers/entities";
import { ComponentProps } from "react";
import set from "lodash/fp/set";
@ -22,7 +22,7 @@ const defaultArgs: ComponentProps<typeof SecurityPageView> = {
userLoginType: {
login_type: "password",
},
authMethods: MockAuthMethods,
authMethods: MockAuthMethodsPasswordOnly,
closeConfirmation: action("closeConfirmation"),
confirm: action("confirm"),
error: undefined,
@ -52,11 +52,7 @@ export const NoOIDCAvailable: Story = {
};
export const UserLoginTypeIsPassword: Story = {
args: set(
"oidc.section.authMethods",
MockAuthMethodsWithPasswordType,
defaultArgs,
),
args: set("oidc.section.authMethods", MockAuthMethodsAll, defaultArgs),
};
export const ConfirmingOIDCConversion: Story = {
@ -64,7 +60,7 @@ export const ConfirmingOIDCConversion: Story = {
"oidc.section",
{
...defaultArgs.oidc?.section,
authMethods: MockAuthMethodsWithPasswordType,
authMethods: MockAuthMethodsAll,
isConfirming: true,
},
defaultArgs,

View File

@ -7,7 +7,7 @@ import KeyIcon from "@mui/icons-material/VpnKey";
import Button from "@mui/material/Button";
import Typography from "@mui/material/Typography";
import { convertToOAUTH } from "api/api";
import {
import type {
AuthMethods,
LoginType,
OIDCAuthMethod,
@ -19,7 +19,6 @@ import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { getErrorMessage } from "api/errors";
import CheckCircleOutlined from "@mui/icons-material/CheckCircleOutlined";
import { EmptyState } from "components/EmptyState/EmptyState";
import { makeStyles } from "@mui/styles";
import Link from "@mui/material/Link";
import { docs } from "utils/docs";
@ -101,21 +100,15 @@ export const useSingleSignOnSection = () => {
};
};
const useEmptyStateStyles = makeStyles((theme) => ({
root: {
minHeight: 0,
padding: theme.spacing(6, 4),
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius,
},
}));
function SSOEmptyState() {
const styles = useEmptyStateStyles();
return (
<EmptyState
className={styles.root}
css={(theme) => ({
minHeight: 0,
padding: theme.spacing(6, 4),
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius,
})}
message="No SSO Providers"
description="No SSO providers are configured with this Coder deployment."
cta={

View File

@ -1,12 +1,9 @@
import { FC, PropsWithChildren } from "react";
import { type FC, type PropsWithChildren } from "react";
import { Section } from "components/SettingsLayout/Section";
import { WorkspaceProxyView } from "./WorkspaceProxyView";
import makeStyles from "@mui/styles/makeStyles";
import { useProxy } from "contexts/ProxyContext";
export const WorkspaceProxyPage: FC<PropsWithChildren<unknown>> = () => {
const styles = useStyles();
const description =
"Workspace proxies improve terminal and web app connections to workspaces.";
@ -22,7 +19,15 @@ export const WorkspaceProxyPage: FC<PropsWithChildren<unknown>> = () => {
return (
<Section
title="Workspace Proxies"
className={styles.section}
css={(theme) => ({
"& code": {
background: theme.palette.divider,
fontSize: 12,
padding: "2px 4px",
color: theme.palette.text.primary,
borderRadius: 2,
},
})}
description={description}
layout="fluid"
>
@ -38,16 +43,4 @@ export const WorkspaceProxyPage: FC<PropsWithChildren<unknown>> = () => {
);
};
const useStyles = makeStyles((theme) => ({
section: {
"& code": {
background: theme.palette.divider,
fontSize: 12,
padding: "2px 4px",
color: theme.palette.text.primary,
borderRadius: 2,
},
},
}));
export default WorkspaceProxyPage;

View File

@ -21,12 +21,11 @@ const renderPage = () => {
const suspendUser = async () => {
const user = userEvent.setup();
// Get the first user in the table
const moreButtons = await screen.findAllByLabelText("more");
const moreButtons = await screen.findAllByLabelText("More options");
const firstMoreButton = moreButtons[0];
await user.click(firstMoreButton);
const menu = await screen.findByRole("menu");
const suspendButton = within(menu).getByText(/Suspend/);
const suspendButton = screen.getByTestId("suspend-button");
await user.click(suspendButton);
// Check if the confirm message is displayed
@ -39,17 +38,15 @@ const suspendUser = async () => {
const deleteUser = async () => {
const user = userEvent.setup();
// Click on the "more" button to display the "Delete" option
// Click on the "More options" button to display the "Delete" option
// Needs to await fetching users and fetching permissions, because they're needed to see the more button
const moreButtons = await screen.findAllByLabelText("more");
const moreButtons = await screen.findAllByLabelText("More options");
// get MockUser2
const selectedMoreButton = moreButtons[1];
await user.click(selectedMoreButton);
const menu = await screen.findByRole("menu");
const deleteButton = within(menu).getByText(/Delete/);
const deleteButton = screen.getByText(/Delete/);
await user.click(deleteButton);
// Check if the confirm message is displayed
@ -67,12 +64,11 @@ const deleteUser = async () => {
};
const activateUser = async () => {
const moreButtons = await screen.findAllByLabelText("more");
const moreButtons = await screen.findAllByLabelText("More options");
const suspendedMoreButton = moreButtons[2];
fireEvent.click(suspendedMoreButton);
const menu = screen.getByRole("menu");
const activateButton = within(menu).getByText(/Activate/);
const activateButton = screen.getByText(/Activate/);
fireEvent.click(activateButton);
// Check if the confirm message is displayed
@ -86,14 +82,11 @@ const activateUser = async () => {
};
const resetUserPassword = async (setupActionSpies: () => void) => {
const moreButtons = await screen.findAllByLabelText("more");
const moreButtons = await screen.findAllByLabelText("More options");
const firstMoreButton = moreButtons[0];
fireEvent.click(firstMoreButton);
const menu = screen.getByRole("menu");
const resetPasswordButton = within(menu).getByText(/Reset password/);
const resetPasswordButton = screen.getByText(/Reset password/);
fireEvent.click(resetPasswordButton);
// Check if the confirm message is displayed
@ -135,6 +128,8 @@ const updateUserRole = async (role: Role) => {
};
};
jest.spyOn(console, "error").mockImplementation(() => {});
describe("UsersPage", () => {
describe("suspend user", () => {
describe("when it is success", () => {

View File

@ -4,7 +4,7 @@ import {
MockUser2,
MockAssignableSiteRoles,
mockApiError,
MockAuthMethods,
MockAuthMethodsPasswordOnly,
} from "testHelpers/entities";
import { UsersPageView } from "./UsersPageView";
import { ComponentProps } from "react";
@ -37,7 +37,7 @@ const meta: Meta<typeof UsersPageView> = {
count: 2,
canEditUsers: true,
filterProps: defaultFilterProps,
authMethods: MockAuthMethods,
authMethods: MockAuthMethodsPasswordOnly,
},
};

View File

@ -1,7 +1,8 @@
import { css } from "@emotion/css";
import { type Interpolation, type Theme, useTheme } from "@emotion/react";
import IconButton from "@mui/material/IconButton";
import { EditSquare } from "components/Icons/EditSquare";
import { FC } from "react";
import { makeStyles } from "@mui/styles";
import { type FC } from "react";
import { Stack } from "components/Stack/Stack";
import Checkbox from "@mui/material/Checkbox";
import UserIcon from "@mui/icons-material/PersonOutline";
@ -34,15 +35,13 @@ const Option: React.FC<{
isChecked: boolean;
onChange: (roleName: string) => void;
}> = ({ value, name, description, isChecked, onChange }) => {
const styles = useStyles();
return (
<label htmlFor={name} className={styles.option}>
<label htmlFor={name} css={styles.option}>
<Stack direction="row" alignItems="flex-start">
<Checkbox
id={name}
size="small"
className={styles.checkbox}
css={styles.checkbox}
value={value}
checked={isChecked}
onChange={(e) => {
@ -51,7 +50,7 @@ const Option: React.FC<{
/>
<Stack spacing={0}>
<strong>{name}</strong>
<span className={styles.optionDescription}>{description}</span>
<span css={styles.optionDescription}>{description}</span>
</Stack>
</Stack>
</label>
@ -77,7 +76,7 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
userLoginType,
oidcRoleSync,
}) => {
const styles = useStyles();
const theme = useTheme();
const handleChange = (roleName: string) => {
if (selectedRoleNames.has(roleName)) {
@ -108,20 +107,28 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
<PopoverTrigger>
<IconButton
size="small"
className={styles.editButton}
css={styles.editButton}
title="Edit user roles"
>
<EditSquare />
</IconButton>
</PopoverTrigger>
<PopoverContent classes={{ paper: styles.popoverPaper }}>
<PopoverContent
classes={{
paper: css`
width: ${theme.spacing(45)};
margin-top: ${theme.spacing(1)};
background: ${theme.palette.background.paperLight};
`,
}}
>
<fieldset
className={styles.fieldset}
css={styles.fieldset}
disabled={isLoading}
title="Available roles"
>
<Stack className={styles.options} spacing={3}>
<Stack css={styles.options} spacing={3}>
{roles.map((role) => (
<Option
key={role.name}
@ -134,12 +141,12 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
))}
</Stack>
</fieldset>
<div className={styles.footer}>
<div css={styles.footer}>
<Stack direction="row" alignItems="flex-start">
<UserIcon className={styles.userIcon} />
<UserIcon css={styles.userIcon} />
<Stack spacing={0}>
<strong>Member</strong>
<span className={styles.optionDescription}>
<span css={styles.optionDescription}>
{roleDescriptions.member}
</span>
</Stack>
@ -150,8 +157,8 @@ export const EditRolesButton: FC<EditRolesButtonProps> = ({
);
};
const useStyles = makeStyles((theme) => ({
editButton: {
const styles = {
editButton: (theme) => ({
color: theme.palette.text.secondary,
"& .MuiSvgIcon-root": {
@ -165,12 +172,7 @@ const useStyles = makeStyles((theme) => ({
color: theme.palette.text.primary,
backgroundColor: "transparent",
},
},
popoverPaper: {
width: theme.spacing(45),
marginTop: theme.spacing(1),
background: theme.palette.background.paperLight,
},
}),
fieldset: {
border: 0,
margin: 0,
@ -180,14 +182,14 @@ const useStyles = makeStyles((theme) => ({
opacity: 0.5,
},
},
options: {
options: (theme) => ({
padding: theme.spacing(3),
},
}),
option: {
cursor: "pointer",
fontSize: 14,
},
checkbox: {
checkbox: (theme) => ({
padding: 0,
position: "relative",
top: 1, // Alignment
@ -196,21 +198,21 @@ const useStyles = makeStyles((theme) => ({
width: theme.spacing(2.5),
height: theme.spacing(2.5),
},
},
optionDescription: {
}),
optionDescription: (theme) => ({
fontSize: 13,
color: theme.palette.text.secondary,
lineHeight: "160%",
},
footer: {
}),
footer: (theme) => ({
padding: theme.spacing(3),
backgroundColor: theme.palette.background.paper,
borderTop: `1px solid ${theme.palette.divider}`,
fontSize: 14,
},
userIcon: {
}),
userIcon: (theme) => ({
width: theme.spacing(2.5), // Same as the checkbox
height: theme.spacing(2.5),
color: theme.palette.primary.main,
},
}));
}),
} satisfies Record<string, Interpolation<Theme>>;

View File

@ -90,7 +90,7 @@ export function UserGroupsCell({ userGroups }: GroupsCellProps) {
flexFlow: "column nowrap",
fontSize: theme.typography.body2.fontSize,
padding: theme.spacing(0.5, 0.25),
gap: theme.spacing(0),
gap: 0,
}}
>
{userGroups.map((group) => {

View File

@ -2,7 +2,7 @@ import {
MockUser,
MockUser2,
MockAssignableSiteRoles,
MockAuthMethods,
MockAuthMethodsPasswordOnly,
MockGroup,
} from "testHelpers/entities";
import { UsersTable } from "./UsersTable";
@ -18,7 +18,7 @@ const meta: Meta<typeof UsersTable> = {
component: UsersTable,
args: {
isNonInitialPage: false,
authMethods: MockAuthMethods,
authMethods: MockAuthMethodsPasswordOnly,
},
};

View File

@ -67,7 +67,7 @@ export const UsersTable: FC<React.PropsWithChildren<UsersTableProps>> = ({
}) => {
return (
<TableContainer>
<Table>
<Table data-testid="users-table">
<TableHead>
<TableRow>
<TableCell width="29%">{Language.usernameLabel}</TableCell>

View File

@ -15,7 +15,6 @@ import {
TableLoaderSkeleton,
TableRowSkeleton,
} from "components/TableLoader/TableLoader";
import { TableRowMenu } from "components/TableRowMenu/TableRowMenu";
import { EnterpriseBadge } from "components/DeploySettingsLayout/Badges";
import HideSourceOutlined from "@mui/icons-material/HideSourceOutlined";
import KeyOutlined from "@mui/icons-material/KeyOutlined";
@ -26,6 +25,13 @@ import { LastSeen } from "components/LastSeen/LastSeen";
import { UserRoleCell } from "./UserRoleCell";
import { type GroupsByUserId } from "api/queries/groups";
import { UserGroupsCell } from "./UserGroupsCell";
import {
MoreMenu,
MoreMenuTrigger,
MoreMenuContent,
MoreMenuItem,
} from "components/MoreMenu/MoreMenu";
import Divider from "@mui/material/Divider";
dayjs.extend(relativeTime);
@ -176,48 +182,49 @@ export const UsersTableBody: FC<
{canEditUsers && (
<TableCell>
<TableRowMenu
data={user}
menuItems={[
// Return either suspend or activate depending on status
user.status === "active" || user.status === "dormant"
? {
label: <>Suspend&hellip;</>,
onClick: onSuspendUser,
disabled: false,
}
: {
label: <>Activate&hellip;</>,
onClick: onActivateUser,
disabled: false,
},
{
label: <>Delete&hellip;</>,
onClick: onDeleteUser,
disabled: user.id === actorID,
},
{
label: <>Reset password&hellip;</>,
onClick: onResetUserPassword,
disabled: user.login_type !== "password",
},
{
label: "View workspaces",
onClick: onListWorkspaces,
disabled: false,
},
{
label: (
<>
View activity
{!canViewActivity && <EnterpriseBadge />}
</>
),
onClick: onViewActivity,
disabled: !canViewActivity,
},
]}
/>
<MoreMenu>
<MoreMenuTrigger />
<MoreMenuContent>
{user.status === "active" || user.status === "dormant" ? (
<MoreMenuItem
data-testid="suspend-button"
onClick={() => {
onSuspendUser(user);
}}
>
Suspend&hellip;
</MoreMenuItem>
) : (
<MoreMenuItem onClick={() => onActivateUser(user)}>
Activate&hellip;
</MoreMenuItem>
)}
<MoreMenuItem onClick={() => onListWorkspaces(user)}>
View workspaces
</MoreMenuItem>
<MoreMenuItem
onClick={() => onViewActivity(user)}
disabled={!canViewActivity}
>
View activity
{!canViewActivity && <EnterpriseBadge />}
</MoreMenuItem>
<MoreMenuItem
onClick={() => onResetUserPassword(user)}
disabled={user.login_type !== "password"}
>
Reset password&hellip;
</MoreMenuItem>
<Divider />
<MoreMenuItem
onClick={() => onDeleteUser(user)}
disabled={user.id === actorID}
danger
>
Delete&hellip;
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
</TableCell>
)}
</TableRow>

View File

@ -1,10 +1,10 @@
import { type Interpolation, type Theme } from "@emotion/react";
import { BuildAvatar } from "components/BuildAvatar/BuildAvatar";
import { FC } from "react";
import { type FC } from "react";
import { ProvisionerJobLog, WorkspaceBuild } from "api/typesGenerated";
import { Loader } from "components/Loader/Loader";
import { Stack } from "components/Stack/Stack";
import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs";
import { makeStyles } from "@mui/styles";
import {
FullWidthPageHeader,
PageHeaderTitle,
@ -48,8 +48,6 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
builds,
activeBuildNumber,
}) => {
const styles = useStyles();
if (!build) {
return <Loader />;
}
@ -65,9 +63,9 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
</div>
</Stack>
<Stats aria-label="Build details" className={styles.stats}>
<Stats aria-label="Build details" css={styles.stats}>
<StatsItem
className={styles.statsItem}
css={styles.statsItem}
label="Workspace"
value={
<Link
@ -78,22 +76,22 @@ export const WorkspaceBuildPageView: FC<WorkspaceBuildPageViewProps> = ({
}
/>
<StatsItem
className={styles.statsItem}
css={styles.statsItem}
label="Template version"
value={build.template_version_name}
/>
<StatsItem
className={styles.statsItem}
css={styles.statsItem}
label="Duration"
value={displayWorkspaceBuildDuration(build)}
/>
<StatsItem
className={styles.statsItem}
css={styles.statsItem}
label="Started at"
value={new Date(build.created_at).toLocaleString()}
/>
<StatsItem
className={styles.statsItem}
css={styles.statsItem}
label="Action"
value={
<Box component="span" sx={{ textTransform: "capitalize" }}>
@ -240,8 +238,8 @@ const BuildSidebarItemSkeleton = () => {
);
};
const useStyles = makeStyles((theme) => ({
stats: {
const styles = {
stats: (theme) => ({
padding: 0,
border: 0,
gap: theme.spacing(6),
@ -254,7 +252,7 @@ const useStyles = makeStyles((theme) => ({
alignItems: "flex-start",
gap: theme.spacing(1),
},
},
}),
statsItem: {
flexDirection: "column",
@ -266,4 +264,4 @@ const useStyles = makeStyles((theme) => ({
fontWeight: 500,
},
},
}));
} satisfies Record<string, Interpolation<Theme>>;

View File

@ -58,7 +58,11 @@ export const BuildRow: React.FC<BuildRowProps> = ({ build }) => {
</span>
</Stack>
<Stack direction="row" spacing={1}>
<Stack
direction="row"
spacing={1}
css={{ "& strong": { fontWeight: 600 } }}
>
<span css={styles.buildInfo}>
Reason: <strong>{build.reason}</strong>
</span>
@ -94,6 +98,9 @@ const styles = {
buildSummary: (theme) => ({
...(theme.typography.body1 as CSSObject),
fontFamily: "inherit",
"& strong": {
fontWeight: 600,
},
}),
buildInfo: (theme) => ({

View File

@ -1,9 +1,6 @@
import MenuItem from "@mui/material/MenuItem";
import Menu from "@mui/material/Menu";
import { makeStyles } from "@mui/styles";
import MoreVertOutlined from "@mui/icons-material/MoreVertOutlined";
import { FC, Fragment, ReactNode, useRef, useState } from "react";
import { FC, Fragment, ReactNode } from "react";
import { Workspace, WorkspaceBuildParameter } from "api/typesGenerated";
import { useWorkspaceDuplication } from "pages/CreateWorkspacePage/useWorkspaceDuplication";
import {
ActionLoadingButton,
CancelButton,
@ -19,10 +16,19 @@ import {
ButtonTypesEnum,
actionsByWorkspaceStatus,
} from "./constants";
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
import HistoryOutlined from "@mui/icons-material/HistoryOutlined";
import DeleteOutlined from "@mui/icons-material/DeleteOutlined";
import IconButton from "@mui/material/IconButton";
import Divider from "@mui/material/Divider";
import DuplicateIcon from "@mui/icons-material/FileCopyOutlined";
import SettingsIcon from "@mui/icons-material/SettingsOutlined";
import HistoryIcon from "@mui/icons-material/HistoryOutlined";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import {
MoreMenu,
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
} from "components/MoreMenu/MoreMenu";
export interface WorkspaceActionsProps {
workspace: Workspace;
@ -56,7 +62,6 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
isRestarting,
canChangeVersions,
}) => {
const styles = useStyles();
const {
canCancel,
canAcceptJobs,
@ -67,8 +72,8 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
canChangeVersions,
);
const canBeUpdated = workspace.outdated && canAcceptJobs;
const menuTriggerRef = useRef<HTMLButtonElement>(null);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { duplicateWorkspace, isDuplicationReady } =
useWorkspaceDuplication(workspace);
// A mapping of button type to the corresponding React component
const buttonMapping: ButtonMapping = {
@ -108,70 +113,70 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
),
};
// Returns a function that will execute the action and close the menu
const onMenuItemClick = (actionFn: () => void) => () => {
setIsMenuOpen(false);
actionFn();
};
return (
<div className={styles.actions} data-testid="workspace-actions">
<div
css={(theme) => ({
display: "flex",
alignItems: "center",
gap: theme.spacing(1.5),
})}
data-testid="workspace-actions"
>
{canBeUpdated &&
(isUpdating
? buttonMapping[ButtonTypesEnum.updating]
: buttonMapping[ButtonTypesEnum.update])}
{isRestarting && buttonMapping[ButtonTypesEnum.restarting]}
{!isRestarting &&
actionsByStatus.map((action) => (
<Fragment key={action}>{buttonMapping[action]}</Fragment>
))}
{canCancel && <CancelButton handleAction={handleCancel} />}
<div>
<IconButton
<MoreMenu>
<MoreMenuTrigger
title="More options"
size="small"
data-testid="workspace-options-button"
aria-controls="workspace-options"
aria-haspopup="true"
disabled={!canAcceptJobs}
ref={menuTriggerRef}
onClick={() => setIsMenuOpen(true)}
>
<MoreVertOutlined />
</IconButton>
<Menu
id="workspace-options"
anchorEl={menuTriggerRef.current}
open={isMenuOpen}
onClose={() => setIsMenuOpen(false)}
>
<MenuItem onClick={onMenuItemClick(handleSettings)}>
<SettingsOutlined />
/>
<MoreMenuContent id="workspace-options">
<MoreMenuItem onClick={handleSettings}>
<SettingsIcon />
Settings
</MenuItem>
</MoreMenuItem>
{canChangeVersions && (
<MenuItem onClick={onMenuItemClick(handleChangeVersion)}>
<HistoryOutlined />
<MoreMenuItem onClick={handleChangeVersion}>
<HistoryIcon />
Change version&hellip;
</MenuItem>
</MoreMenuItem>
)}
<MenuItem
onClick={onMenuItemClick(handleDelete)}
<MoreMenuItem
onClick={duplicateWorkspace}
disabled={!isDuplicationReady}
>
<DuplicateIcon />
Duplicate&hellip;
</MoreMenuItem>
<Divider />
<MoreMenuItem
danger
onClick={handleDelete}
data-testid="delete-button"
>
<DeleteOutlined />
<DeleteIcon />
Delete&hellip;
</MenuItem>
</Menu>
</div>
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
</div>
);
};
const useStyles = makeStyles((theme) => ({
actions: {
display: "flex",
alignItems: "center",
gap: theme.spacing(1.5),
},
}));

View File

@ -1,8 +1,9 @@
import { css } from "@emotion/css";
import { type Interpolation, type Theme } from "@emotion/react";
import LinearProgress from "@mui/material/LinearProgress";
import makeStyles from "@mui/styles/makeStyles";
import { TransitionStats, Template, Workspace } from "api/typesGenerated";
import dayjs, { Dayjs } from "dayjs";
import { FC, useEffect, useState } from "react";
import type { TransitionStats, Template, Workspace } from "api/typesGenerated";
import dayjs, { type Dayjs } from "dayjs";
import { type FC, useEffect, useState } from "react";
import capitalize from "lodash/capitalize";
import duration from "dayjs/plugin/duration";
@ -68,7 +69,6 @@ export const WorkspaceBuildProgress: FC<WorkspaceBuildProgressProps> = ({
workspace,
transitionStats: transitionStats,
}) => {
const styles = useStyles();
const job = workspace.latest_build.job;
const [progressValue, setProgressValue] = useState<number | undefined>(0);
const [progressText, setProgressText] = useState<string | undefined>(
@ -107,7 +107,7 @@ export const WorkspaceBuildProgress: FC<WorkspaceBuildProgressProps> = ({
return <></>;
}
return (
<div className={styles.stack}>
<div css={styles.stack}>
<LinearProgress
data-chromatic="ignore"
value={progressValue !== undefined ? progressValue : 0}
@ -123,13 +123,17 @@ export const WorkspaceBuildProgress: FC<WorkspaceBuildProgressProps> = ({
// If a transition is set, there is a moment on new load where the
// bar accelerates to progressValue and then rapidly decelerates, which
// is not indicative of true progress.
classes={{ bar: styles.noTransition }}
classes={{
bar: css`
transition: none;
`,
}}
/>
<div className={styles.barHelpers}>
<div className={styles.label}>
<div css={styles.barHelpers}>
<div css={styles.label}>
{capitalize(workspace.latest_build.status)} workspace...
</div>
<div className={styles.label} data-chromatic="ignore">
<div css={styles.label} data-chromatic="ignore">
{progressText}
</div>
</div>
@ -137,23 +141,20 @@ export const WorkspaceBuildProgress: FC<WorkspaceBuildProgressProps> = ({
);
};
const useStyles = makeStyles((theme) => ({
stack: {
const styles = {
stack: (theme) => ({
paddingLeft: theme.spacing(0.2),
paddingRight: theme.spacing(0.2),
},
noTransition: {
transition: "none",
},
barHelpers: {
}),
barHelpers: (theme) => ({
display: "flex",
justifyContent: "space-between",
marginTop: theme.spacing(0.5),
},
label: {
}),
label: (theme) => ({
fontSize: 12,
display: "block",
fontWeight: 600,
color: theme.palette.text.secondary,
},
}));
}),
} satisfies Record<string, Interpolation<Theme>>;

Some files were not shown because too many files have changed in this diff Show More