mirror of https://github.com/coder/coder.git
Merge remote-tracking branch 'origin/main' into lowercase-workspace-name/kira-pilot
This commit is contained in:
commit
fff170bb77
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -97,6 +97,7 @@ func (r *RootCmd) Core() []*clibase.Cmd {
|
|||
r.version(defaultVersionInfo),
|
||||
|
||||
// Workspace Commands
|
||||
r.autoupdate(),
|
||||
r.configSSH(),
|
||||
r.create(),
|
||||
r.deleteWorkspace(),
|
||||
|
|
|
@ -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.
|
||||
|
|
130
cli/start.go
130
cli/start.go
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -35,6 +35,7 @@ const (
|
|||
EnhancedExternalAuthProviderGitLab EnhancedExternalAuthProvider = "gitlab"
|
||||
EnhancedExternalAuthProviderBitBucket EnhancedExternalAuthProvider = "bitbucket"
|
||||
EnhancedExternalAuthProviderSlack EnhancedExternalAuthProvider = "slack"
|
||||
EnhancedExternalAuthProviderJFrog EnhancedExternalAuthProvider = "jfrog"
|
||||
)
|
||||
|
||||
type ExternalAuth struct {
|
||||
|
|
|
@ -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 |
|
||||
|
|
|
@ -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.
|
|
@ -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
|
||||
|
||||
| | |
|
||||
|
|
|
@ -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
|
||||
|
||||
| | |
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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.")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -5,3 +5,10 @@ export const uploadFile = () => {
|
|||
mutationFn: API.uploadFile,
|
||||
};
|
||||
};
|
||||
|
||||
export const file = (fileId: string) => {
|
||||
return {
|
||||
queryKey: ["files", fileId],
|
||||
queryFn: () => API.getFile(fileId),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -1684,12 +1684,14 @@ export type EnhancedExternalAuthProvider =
|
|||
| "bitbucket"
|
||||
| "github"
|
||||
| "gitlab"
|
||||
| "jfrog"
|
||||
| "slack";
|
||||
export const EnhancedExternalAuthProviders: EnhancedExternalAuthProvider[] = [
|
||||
"azure-devops",
|
||||
"bitbucket",
|
||||
"github",
|
||||
"gitlab",
|
||||
"jfrog",
|
||||
"slack",
|
||||
];
|
||||
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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're noticing DERP proxy issues.
|
||||
</HealthIssue>
|
||||
)}
|
||||
{health.websocket && (
|
||||
{!health.websocket.healthy && (
|
||||
<HealthIssue>
|
||||
We'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>
|
||||
);
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -76,6 +76,7 @@ export const UserDropdown: FC<PropsWithChildren<UserDropdownProps>> = ({
|
|||
horizontal="right"
|
||||
css={(theme) => ({
|
||||
".MuiPaper-root": {
|
||||
minWidth: "auto",
|
||||
width: 260,
|
||||
boxShadow: theme.shadows[6],
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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 (
|
||||
|
|
|
@ -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 };
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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 },
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
}));
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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>>;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>>;
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
|
|
@ -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'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
|
||||
<Link href={docs("/admin/rbac")}>
|
||||
'Template RBAC'
|
||||
|
@ -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>>;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 ?? "";
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ const meta: Meta<typeof CreateWorkspacePageView> = {
|
|||
template: MockTemplate,
|
||||
parameters: [],
|
||||
externalAuth: [],
|
||||
mode: "form",
|
||||
permissions: {
|
||||
createWorkspaceForUser: true,
|
||||
},
|
||||
|
|
|
@ -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>>;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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't have any licenses!
|
||||
</span>
|
||||
<span className={styles.description}>
|
||||
<span css={styles.title}>You don't have any licenses!</span>
|
||||
<span css={styles.description}>
|
||||
You'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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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‘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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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>>;
|
||||
|
|
|
@ -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…
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
onClick={onMenuItemClick(dialogState.openDeleteConfirmation)}
|
||||
>
|
||||
</MoreMenuItem>
|
||||
<Divider />
|
||||
<MoreMenuItem onClick={dialogState.openDeleteConfirmation} danger>
|
||||
<DeleteIcon />
|
||||
Delete…
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
</MoreMenuItem>
|
||||
</MoreMenuContent>
|
||||
</MoreMenu>
|
||||
|
||||
{safeToDeleteTemplate ? (
|
||||
<DeleteDialog
|
||||
|
|
|
@ -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>>;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -118,7 +118,6 @@ export const ScheduleDialog: FC<PropsWithChildren<ScheduleDialogProps>> = ({
|
|||
<DialogActions>
|
||||
<DialogActionButtons
|
||||
cancelText={cancelText}
|
||||
confirmDialog
|
||||
confirmLoading={confirmLoading}
|
||||
confirmText="Submit"
|
||||
disabled={disabled}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
]}
|
||||
>
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
};
|
|
@ -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>>;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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={
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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", () => {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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>>;
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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…</>,
|
||||
onClick: onSuspendUser,
|
||||
disabled: false,
|
||||
}
|
||||
: {
|
||||
label: <>Activate…</>,
|
||||
onClick: onActivateUser,
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: <>Delete…</>,
|
||||
onClick: onDeleteUser,
|
||||
disabled: user.id === actorID,
|
||||
},
|
||||
{
|
||||
label: <>Reset password…</>,
|
||||
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…
|
||||
</MoreMenuItem>
|
||||
) : (
|
||||
<MoreMenuItem onClick={() => onActivateUser(user)}>
|
||||
Activate…
|
||||
</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…
|
||||
</MoreMenuItem>
|
||||
<Divider />
|
||||
<MoreMenuItem
|
||||
onClick={() => onDeleteUser(user)}
|
||||
disabled={user.id === actorID}
|
||||
danger
|
||||
>
|
||||
Delete…
|
||||
</MoreMenuItem>
|
||||
</MoreMenuContent>
|
||||
</MoreMenu>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
|
|
|
@ -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>>;
|
||||
|
|
|
@ -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) => ({
|
||||
|
|
|
@ -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…
|
||||
</MenuItem>
|
||||
</MoreMenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
onClick={onMenuItemClick(handleDelete)}
|
||||
|
||||
<MoreMenuItem
|
||||
onClick={duplicateWorkspace}
|
||||
disabled={!isDuplicationReady}
|
||||
>
|
||||
<DuplicateIcon />
|
||||
Duplicate…
|
||||
</MoreMenuItem>
|
||||
|
||||
<Divider />
|
||||
|
||||
<MoreMenuItem
|
||||
danger
|
||||
onClick={handleDelete}
|
||||
data-testid="delete-button"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
<DeleteIcon />
|
||||
Delete…
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</div>
|
||||
</MoreMenuItem>
|
||||
</MoreMenuContent>
|
||||
</MoreMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
actions: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing(1.5),
|
||||
},
|
||||
}));
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue