From 300a763560cf37f80cf036e6c54cc9884e89eace Mon Sep 17 00:00:00 2001 From: Garrett Date: Tue, 6 Dec 2022 21:42:24 +0000 Subject: [PATCH 01/53] feat: add examples to api --- cli/templateinit.go | 3 ++- coderd/coderd.go | 1 + coderd/users.go | 15 +++++++++++++++ codersdk/organizations.go | 3 ++- codersdk/templates.go | 8 ++++++++ examples/examples.go | 18 ++++++------------ site/src/api/typesGenerated.ts | 12 +++++++++++- 7 files changed, 45 insertions(+), 15 deletions(-) diff --git a/cli/templateinit.go b/cli/templateinit.go index ad936024a9f11..39a8f72662bde 100644 --- a/cli/templateinit.go +++ b/cli/templateinit.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/cobra" "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/codersdk" "github.com/coder/coder/examples" "github.com/coder/coder/provisionersdk" ) @@ -22,7 +23,7 @@ func templateInit() *cobra.Command { return err } exampleNames := []string{} - exampleByName := map[string]examples.Example{} + exampleByName := map[string]codersdk.TemplateExample{} for _, example := range exampleList { name := fmt.Sprintf( "%s\n%s\n%s\n", diff --git a/coderd/coderd.go b/coderd/coderd.go index fb36deeec42c3..675d023e82194 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -355,6 +355,7 @@ func New(options *Options) *API { r.Post("/", api.postTemplateByOrganization) r.Get("/", api.templatesByOrganization) r.Get("/{templatename}", api.templateByOrganizationAndName) + r.Get("/examples", api.templateExamples) }) r.Route("/members", func(r chi.Router) { r.Get("/roles", api.assignableOrgRoles) diff --git a/coderd/users.go b/coderd/users.go index b3e42cba75c91..7bf27ce94c9de 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -825,6 +825,21 @@ func (api *API) putUserRoles(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, convertUser(updatedUser, organizationIDs)) } +func (_ *API) templateExamples(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + ex, err := examples.List() + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching examples.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, ex) +} + // updateSiteUserRoles will ensure only site wide roles are passed in as arguments. // If an organization role is included, an error is returned. func (api *API) updateSiteUserRoles(ctx context.Context, args database.UpdateUserRolesParams) (database.User, error) { diff --git a/codersdk/organizations.go b/codersdk/organizations.go index c61161abd7dcf..2b9f7e906d4a5 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -38,7 +38,8 @@ type CreateTemplateVersionRequest struct { // TemplateID optionally associates a version with a template. TemplateID uuid.UUID `json:"template_id,omitempty"` StorageMethod ProvisionerStorageMethod `json:"storage_method" validate:"oneof=file,required"` - FileID uuid.UUID `json:"file_id" validate:"required"` + FileID uuid.UUID `json:"file_id,omitempty" validate:"required_without=ExampleID"` + ExampleID uuid.UUID `json:"example_id,omitempty" validate:"required_without=FileID"` Provisioner ProvisionerType `json:"provisioner" validate:"oneof=terraform echo,required"` ProvisionerTags map[string]string `json:"tags"` diff --git a/codersdk/templates.go b/codersdk/templates.go index 9a6b6d8ec4c58..e042a056a6e70 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -82,6 +82,14 @@ type UpdateTemplateMeta struct { AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs,omitempty"` } +type TemplateExample struct { + ID string `json:"id"` + URL string `json:"url"` + Name string `json:"name"` + Description string `json:"description"` + Markdown string `json:"markdown"` +} + // Template returns a single template. func (c *Client) Template(ctx context.Context, template uuid.UUID) (Template, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/templates/%s", template), nil) diff --git a/examples/examples.go b/examples/examples.go index 62c08d46b15af..d34169d8bd489 100644 --- a/examples/examples.go +++ b/examples/examples.go @@ -12,6 +12,8 @@ import ( "github.com/gohugoio/hugo/parser/pageparser" "golang.org/x/sync/singleflight" "golang.org/x/xerrors" + + "github.com/coder/coder/codersdk" ) var ( @@ -19,23 +21,15 @@ var ( files embed.FS exampleBasePath = "https://github.com/coder/coder/tree/main/examples/templates/" - examples = make([]Example, 0) + examples = make([]codersdk.TemplateExample, 0) parseExamples sync.Once archives = singleflight.Group{} ) -type Example struct { - ID string `json:"id"` - URL string `json:"url"` - Name string `json:"name"` - Description string `json:"description"` - Markdown string `json:"markdown"` -} - const rootDir = "templates" // List returns all embedded examples. -func List() ([]Example, error) { +func List() ([]codersdk.TemplateExample, error) { var returnError error parseExamples.Do(func() { files, err := fs.Sub(files, rootDir) @@ -92,7 +86,7 @@ func List() ([]Example, error) { return } - examples = append(examples, Example{ + examples = append(examples, codersdk.TemplateExample{ ID: exampleID, URL: exampleURL, Name: name, @@ -112,7 +106,7 @@ func Archive(exampleID string) ([]byte, error) { return nil, xerrors.Errorf("list: %w", err) } - var selected Example + var selected codersdk.TemplateExample for _, example := range examples { if example.ID != exampleID { continue diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index f18086fa775da..345dc6fa7d819 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -189,7 +189,8 @@ export interface CreateTemplateVersionRequest { readonly name?: string readonly template_id?: string readonly storage_method: ProvisionerStorageMethod - readonly file_id: string + readonly file_id?: string + readonly example_id?: string readonly provisioner: ProvisionerType readonly tags: Record readonly parameter_values?: CreateParameterRequest[] @@ -668,6 +669,15 @@ export interface TemplateDAUsResponse { readonly entries: DAUEntry[] } +// From codersdk/templates.go +export interface TemplateExample { + readonly id: string + readonly url: string + readonly name: string + readonly description: string + readonly markdown: string +} + // From codersdk/templates.go export interface TemplateGroup extends Group { readonly role: TemplateRole From da9fc00a70f94ee9ad97769471e95779ac9ac624 Mon Sep 17 00:00:00 2001 From: Garrett Date: Wed, 7 Dec 2022 16:27:49 +0000 Subject: [PATCH 02/53] add support for example id in route --- coderd/files.go | 6 ++- coderd/templateversions.go | 81 +++++++++++++++++++++++++++++++++----- codersdk/organizations.go | 2 +- examples/examples.go | 3 +- 4 files changed, 80 insertions(+), 12 deletions(-) diff --git a/coderd/files.go b/coderd/files.go index 2c304921f8226..fca345e9c80fd 100644 --- a/coderd/files.go +++ b/coderd/files.go @@ -19,6 +19,10 @@ import ( "github.com/coder/coder/codersdk" ) +const ( + uploadFileContentTypeHeader = "application/x-tar" +) + func (api *API) postFile(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() apiKey := httpmw.APIKey(r) @@ -32,7 +36,7 @@ func (api *API) postFile(rw http.ResponseWriter, r *http.Request) { contentType := r.Header.Get("Content-Type") switch contentType { - case "application/x-tar": + case uploadFileContentTypeHeader: default: httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: fmt.Sprintf("Unsupported content type header %q.", contentType), diff --git a/coderd/templateversions.go b/coderd/templateversions.go index b7c63cf92c1c1..419dc428bb23f 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -2,7 +2,9 @@ package coderd import ( "context" + "crypto/sha256" "database/sql" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -23,6 +25,7 @@ import ( "github.com/coder/coder/coderd/provisionerdserver" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/codersdk" + "github.com/coder/coder/examples" ) func (api *API) templateVersion(rw http.ResponseWriter, r *http.Request) { @@ -834,19 +837,79 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht // Ensures the "owner" is properly applied. tags := provisionerdserver.MutateTags(apiKey.UserID, req.ProvisionerTags) - file, err := api.Database.GetFileByID(ctx, req.FileID) - if errors.Is(err, sql.ErrNoRows) { - httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ - Message: "File not found.", + if req.ExampleID != "" && req.FileID != uuid.Nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "You cannot specify both an example_id and a file_id.", }) return } - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching file.", - Detail: err.Error(), + + var file database.File + var err error + // if example id is specified we need to copy the embedded tar into a new file in the database + if req.ExampleID != "" { + if !api.Authorize(r, rbac.ActionCreate, rbac.ResourceFile.WithOwner(apiKey.UserID.String())) { + httpapi.Forbidden(rw) + return + } + // ensure we can read the file that either already exists or will be created + if !api.Authorize(r, rbac.ActionRead, rbac.ResourceFile.WithOwner(apiKey.UserID.String())) { + httpapi.Forbidden(rw) + return + } + + // lookup template tar from embedded examples + tar, err := examples.Archive(req.ExampleID) + if err != nil { + if xerrors.Is(err, examples.ErrNotFound) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Example not found.", + Detail: err.Error(), + }) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching example.", + Detail: err.Error(), + }) + return + } + + // upload a copy of the template tar as a file in the database + hashBytes := sha256.Sum256(tar) + hash := hex.EncodeToString(hashBytes[:]) + file, err = api.Database.InsertFile(ctx, database.InsertFileParams{ + ID: uuid.New(), + Hash: hash, + CreatedBy: apiKey.UserID, + CreatedAt: database.Now(), + Mimetype: uploadFileContentTypeHeader, + Data: tar, }) - return + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error creating file.", + Detail: err.Error(), + }) + return + } + } + + if req.FileID != uuid.Nil { + file, err = api.Database.GetFileByID(ctx, req.FileID) + if errors.Is(err, sql.ErrNoRows) { + httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{ + Message: "File not found.", + }) + return + } + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching file.", + Detail: err.Error(), + }) + return + } } if !api.Authorize(r, rbac.ActionRead, file) { diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 2b9f7e906d4a5..2c15e78db1c69 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -39,7 +39,7 @@ type CreateTemplateVersionRequest struct { TemplateID uuid.UUID `json:"template_id,omitempty"` StorageMethod ProvisionerStorageMethod `json:"storage_method" validate:"oneof=file,required"` FileID uuid.UUID `json:"file_id,omitempty" validate:"required_without=ExampleID"` - ExampleID uuid.UUID `json:"example_id,omitempty" validate:"required_without=FileID"` + ExampleID string `json:"example_id,omitempty" validate:"required_without=FileID"` Provisioner ProvisionerType `json:"provisioner" validate:"oneof=terraform echo,required"` ProvisionerTags map[string]string `json:"tags"` diff --git a/examples/examples.go b/examples/examples.go index d34169d8bd489..93894fc863fc8 100644 --- a/examples/examples.go +++ b/examples/examples.go @@ -24,6 +24,7 @@ var ( examples = make([]codersdk.TemplateExample, 0) parseExamples sync.Once archives = singleflight.Group{} + ErrNotFound = xerrors.New("example not found") ) const rootDir = "templates" @@ -116,7 +117,7 @@ func Archive(exampleID string) ([]byte, error) { } if selected.ID == "" { - return nil, xerrors.Errorf("example with id %q not found", exampleID) + return nil, xerrors.Errorf("example with id %q not found: %w", exampleID, ErrNotFound) } exampleFiles, err := fs.Sub(files, path.Join(rootDir, exampleID)) From a9f04f3e4d8e6c4e7cbce68a7da416a2a445f99d Mon Sep 17 00:00:00 2001 From: Garrett Date: Wed, 7 Dec 2022 16:32:36 +0000 Subject: [PATCH 03/53] move files --- coderd/templates.go | 16 ++++++++++++++++ coderd/users.go | 15 --------------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/coderd/templates.go b/coderd/templates.go index 1a97da0dd50a8..a357214eac3b3 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -23,6 +23,7 @@ import ( "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/coderd/telemetry" "github.com/coder/coder/codersdk" + "github.com/coder/coder/examples" ) // Auto-importable templates. These can be auto-imported after the first user @@ -564,6 +565,21 @@ func (api *API) templateDAUs(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, resp) } +func (*API) templateExamples(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + ex, err := examples.List() + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching examples.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, ex) +} + type autoImportTemplateOpts struct { name string archive []byte diff --git a/coderd/users.go b/coderd/users.go index 7bf27ce94c9de..b3e42cba75c91 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -825,21 +825,6 @@ func (api *API) putUserRoles(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, convertUser(updatedUser, organizationIDs)) } -func (_ *API) templateExamples(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - ex, err := examples.List() - if err != nil { - httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching examples.", - Detail: err.Error(), - }) - return - } - - httpapi.Write(ctx, rw, http.StatusOK, ex) -} - // updateSiteUserRoles will ensure only site wide roles are passed in as arguments. // If an organization role is included, an error is returned. func (api *API) updateSiteUserRoles(ctx context.Context, args database.UpdateUserRolesParams) (database.User, error) { From 90e6a9d0dde8f118d53b2fa536acd7aca96f9bbc Mon Sep 17 00:00:00 2001 From: Garrett Date: Wed, 7 Dec 2022 16:39:04 +0000 Subject: [PATCH 04/53] fix existing tests --- coderd/templates.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/coderd/templates.go b/coderd/templates.go index a357214eac3b3..d87247fba8633 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -565,8 +565,16 @@ func (api *API) templateDAUs(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, resp) } -func (*API) templateExamples(rw http.ResponseWriter, r *http.Request) { - ctx := r.Context() +func (api *API) templateExamples(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + organization = httpmw.OrganizationParam(r) + ) + + if !api.Authorize(r, rbac.ActionRead, rbac.ResourceTemplate.InOrg(organization.ID)) { + httpapi.ResourceNotFound(rw) + return + } ex, err := examples.List() if err != nil { From 7084cebfff5d1b72ed9b497596427cdcf14c5a9d Mon Sep 17 00:00:00 2001 From: Garrett Date: Wed, 7 Dec 2022 19:55:41 +0000 Subject: [PATCH 05/53] add tests --- coderd/files.go | 4 +-- coderd/templateversions.go | 2 +- coderd/templateversions_test.go | 46 +++++++++++++++++++++++++++++++++ codersdk/templates.go | 14 ++++++++++ 4 files changed, 63 insertions(+), 3 deletions(-) diff --git a/coderd/files.go b/coderd/files.go index fca345e9c80fd..8d01745f919c6 100644 --- a/coderd/files.go +++ b/coderd/files.go @@ -20,7 +20,7 @@ import ( ) const ( - uploadFileContentTypeHeader = "application/x-tar" + tarMimeType = "application/x-tar" ) func (api *API) postFile(rw http.ResponseWriter, r *http.Request) { @@ -36,7 +36,7 @@ func (api *API) postFile(rw http.ResponseWriter, r *http.Request) { contentType := r.Header.Get("Content-Type") switch contentType { - case uploadFileContentTypeHeader: + case tarMimeType: default: httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: fmt.Sprintf("Unsupported content type header %q.", contentType), diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 419dc428bb23f..8222a1229558d 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -883,7 +883,7 @@ func (api *API) postTemplateVersionsByOrganization(rw http.ResponseWriter, r *ht Hash: hash, CreatedBy: apiKey.UserID, CreatedAt: database.Now(), - Mimetype: uploadFileContentTypeHeader, + Mimetype: tarMimeType, Data: tar, }) if err != nil { diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index 3c752253f61cb..c4b7ee26cee44 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -15,6 +15,7 @@ import ( "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/provisionerdserver" "github.com/coder/coder/codersdk" + "github.com/coder/coder/examples" "github.com/coder/coder/provisioner/echo" "github.com/coder/coder/provisionersdk/proto" "github.com/coder/coder/testutil" @@ -128,6 +129,31 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { require.Len(t, auditor.AuditLogs, 1) assert.Equal(t, database.AuditActionCreate, auditor.AuditLogs[0].Action) }) + t.Run("Example", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + ls, err := examples.List() + require.NoError(t, err) + + tv, err := client.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ + Name: "my-example", + StorageMethod: codersdk.ProvisionerStorageMethodFile, + ExampleID: ls[0].ID, + Provisioner: codersdk.ProvisionerTypeEcho, + }) + require.NoError(t, err) + fl, ct, err := client.Download(ctx, tv.Job.FileID) + require.NoError(t, err) + require.Equal(t, "application/x-tar", ct) + tar, err := examples.Archive(ls[0].ID) + require.NoError(t, err) + require.EqualValues(t, tar, fl) + }) } func TestPatchCancelTemplateVersion(t *testing.T) { @@ -997,3 +1023,23 @@ func TestPreviousTemplateVersion(t *testing.T) { require.Equal(t, previousVersion.ID, result.ID) }) } + +func TestTemplateExamples(t *testing.T) { + t.Parallel() + t.Run("OK", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + ex, err := client.TemplateExamples(ctx, user.OrganizationID) + require.NoError(t, err) + ls, err := examples.List() + require.NoError(t, err) + require.EqualValues(t, ls, ex) + }) +} diff --git a/codersdk/templates.go b/codersdk/templates.go index e042a056a6e70..d73570c2da98c 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -246,3 +246,17 @@ type AgentStatsReportResponse struct { // TxBytes is the number of transmitted bytes. TxBytes int64 `json:"tx_bytes"` } + +// TemplateExamples lists example templates embedded in coder. +func (c *Client) TemplateExamples(ctx context.Context, organizationID uuid.UUID) ([]TemplateExample, error) { + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/organizations/%s/templates/examples", organizationID), nil) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, readBodyAsError(res) + } + var templateExamples []TemplateExample + return templateExamples, json.NewDecoder(res.Body).Decode(&templateExamples) +} From 50fb40502f00ce0a8381a1b40c44a3472dd2af46 Mon Sep 17 00:00:00 2001 From: Garrett Date: Wed, 7 Dec 2022 20:11:39 +0000 Subject: [PATCH 06/53] more tests --- coderd/templateversions_test.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index c4b7ee26cee44..a57e6eade3892 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -140,13 +140,27 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { ls, err := examples.List() require.NoError(t, err) + // try a bad example ID tv, err := client.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ + Name: "my-example", + StorageMethod: codersdk.ProvisionerStorageMethodFile, + ExampleID: "not a real ID", + Provisioner: codersdk.ProvisionerTypeEcho, + }) + require.Error(t, err) + require.ErrorContains(t, err, "not found") + + // try a good example ID + tv, err = client.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ Name: "my-example", StorageMethod: codersdk.ProvisionerStorageMethodFile, ExampleID: ls[0].ID, Provisioner: codersdk.ProvisionerTypeEcho, }) require.NoError(t, err) + require.Equal(t, "my-example", tv.Name) + + // ensure the template tar was uploaded correctly fl, ct, err := client.Download(ctx, tv.Job.FileID) require.NoError(t, err) require.Equal(t, "application/x-tar", ct) From b00a3b92395420ffd7c6c35e75cad21bae88b7cf Mon Sep 17 00:00:00 2001 From: Garrett Date: Wed, 7 Dec 2022 20:15:57 +0000 Subject: [PATCH 07/53] more tests --- coderd/templateversions_test.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index a57e6eade3892..3b0418a50202a 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -141,7 +141,7 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { require.NoError(t, err) // try a bad example ID - tv, err := client.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ + _, err = client.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ Name: "my-example", StorageMethod: codersdk.ProvisionerStorageMethodFile, ExampleID: "not a real ID", @@ -150,8 +150,20 @@ func TestPostTemplateVersionsByOrganization(t *testing.T) { require.Error(t, err) require.ErrorContains(t, err, "not found") + // try file and example IDs + _, err = client.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ + Name: "my-example", + StorageMethod: codersdk.ProvisionerStorageMethodFile, + ExampleID: ls[0].ID, + FileID: uuid.New(), + Provisioner: codersdk.ProvisionerTypeEcho, + }) + require.Error(t, err) + require.ErrorContains(t, err, "example_id") + require.ErrorContains(t, err, "file_id") + // try a good example ID - tv, err = client.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ + tv, err := client.CreateTemplateVersion(ctx, user.OrganizationID, codersdk.CreateTemplateVersionRequest{ Name: "my-example", StorageMethod: codersdk.ProvisionerStorageMethodFile, ExampleID: ls[0].ID, From 86350be74cbfd66152750e671d41fb504486f762 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 8 Dec 2022 15:01:59 +0000 Subject: [PATCH 08/53] Display starter templates --- site/src/AppRouter.tsx | 14 +++++ site/src/api/api.ts | 9 +++ site/src/i18n/en/index.ts | 2 + site/src/i18n/en/starterTemplatesPage.json | 4 ++ .../StarterTemplatesPage.tsx | 28 +++++++++ .../StarterTemplatesPageView.tsx | 39 ++++++++++++ .../starterTemplatesXService.ts | 62 +++++++++++++++++++ 7 files changed, 158 insertions(+) create mode 100644 site/src/i18n/en/starterTemplatesPage.json create mode 100644 site/src/pages/StarterTemplatesPage/StarterTemplatesPage.tsx create mode 100644 site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx create mode 100644 site/src/xServices/starterTemplates/starterTemplatesXService.ts diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 569b0241b5de1..d6d76230492a4 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -92,6 +92,9 @@ const GitAuthPage = lazy(() => import("./pages/GitAuthPage/GitAuthPage")) const TemplateVersionPage = lazy( () => import("./pages/TemplateVersionPage/TemplateVersionPage"), ) +const StarterTemplatesPage = lazy( + () => import("./pages/StarterTemplatesPage/StarterTemplatesPage"), +) export const AppRouter: FC = () => { const xServices = useContext(XServiceContext) @@ -141,6 +144,17 @@ export const AppRouter: FC = () => { } /> + + + + + } + /> + + => { + const response = await axios.get( + `/api/v2/organizations/${organizationId}/templates/examples`, + ) + return response.data +} diff --git a/site/src/i18n/en/index.ts b/site/src/i18n/en/index.ts index 4e2249981005a..b9059c827c278 100644 --- a/site/src/i18n/en/index.ts +++ b/site/src/i18n/en/index.ts @@ -12,6 +12,7 @@ import templateVersionPage from "./templateVersionPage.json" import loginPage from "./loginPage.json" import workspaceChangeVersionPage from "./workspaceChangeVersionPage.json" import serviceBannerSettings from "./serviceBannerSettings.json" +import starterTemplatesPage from "./starterTemplatesPage.json" export const en = { common, @@ -28,4 +29,5 @@ export const en = { loginPage, workspaceChangeVersionPage, serviceBannerSettings, + starterTemplatesPage, } diff --git a/site/src/i18n/en/starterTemplatesPage.json b/site/src/i18n/en/starterTemplatesPage.json new file mode 100644 index 0000000000000..0fa591386de40 --- /dev/null +++ b/site/src/i18n/en/starterTemplatesPage.json @@ -0,0 +1,4 @@ +{ + "title": "Starter Templates", + "subtitle": "Pick one of the built-in templates to start using Coder" +} diff --git a/site/src/pages/StarterTemplatesPage/StarterTemplatesPage.tsx b/site/src/pages/StarterTemplatesPage/StarterTemplatesPage.tsx new file mode 100644 index 0000000000000..1c0937d707d6d --- /dev/null +++ b/site/src/pages/StarterTemplatesPage/StarterTemplatesPage.tsx @@ -0,0 +1,28 @@ +import { useMachine } from "@xstate/react" +import { useOrganizationId } from "hooks/useOrganizationId" +import { FC } from "react" +import { Helmet } from "react-helmet-async" +import { useTranslation } from "react-i18next" +import { pageTitle } from "util/page" +import { starterTemplatesMachine } from "xServices/starterTemplates/starterTemplatesXService" +import { StarterTemplatesPageView } from "./StarterTemplatesPageView" + +const StarterTemplatesPage: FC = () => { + const { t } = useTranslation("starterTemplatesPage") + const organizationId = useOrganizationId() + const [state] = useMachine(starterTemplatesMachine, { + context: { organizationId }, + }) + + return ( + <> + + Codestin Search App + + + + + ) +} + +export default StarterTemplatesPage diff --git a/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx new file mode 100644 index 0000000000000..f5e1409fac17b --- /dev/null +++ b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx @@ -0,0 +1,39 @@ +import { AlertBanner } from "components/AlertBanner/AlertBanner" +import { Maybe } from "components/Conditionals/Maybe" +import { Margins } from "components/Margins/Margins" +import { + PageHeader, + PageHeaderSubtitle, + PageHeaderTitle, +} from "components/PageHeader/PageHeader" +import { FC } from "react" +import { useTranslation } from "react-i18next" +import { StarterTemplatesContext } from "xServices/starterTemplates/starterTemplatesXService" + +export interface StarterTemplatesPageViewProps { + context: StarterTemplatesContext +} + +export const StarterTemplatesPageView: FC = ({ + context, +}) => { + const { t } = useTranslation("starterTemplatesPage") + + return ( + + + {t("title")} + {t("subtitle")} + + + + + + + {context.starterTemplates && + context.starterTemplates.map((example) => ( +
{example.name}
+ ))} +
+ ) +} diff --git a/site/src/xServices/starterTemplates/starterTemplatesXService.ts b/site/src/xServices/starterTemplates/starterTemplatesXService.ts new file mode 100644 index 0000000000000..653490b45fa37 --- /dev/null +++ b/site/src/xServices/starterTemplates/starterTemplatesXService.ts @@ -0,0 +1,62 @@ +import { getTemplateExamples } from "api/api" +import { TemplateExample } from "api/typesGenerated" +import { assign, createMachine } from "xstate" + +export interface StarterTemplatesContext { + organizationId: string + starterTemplates?: TemplateExample[] + error?: unknown +} + +export const starterTemplatesMachine = createMachine( + { + id: "starterTemplates", + predictableActionArguments: true, + schema: { + context: {} as StarterTemplatesContext, + services: {} as { + loadStarterTemplates: { + data: TemplateExample[] + } + }, + }, + tsTypes: {} as import("./starterTemplatesXService.typegen").Typegen0, + initial: "loading", + states: { + loading: { + invoke: { + src: "loadStarterTemplates", + onDone: { + actions: ["assignStarterTemplates"], + target: "idle.ok", + }, + onError: { + actions: ["assignError"], + target: "idle.error", + }, + }, + }, + idle: { + initial: "ok", + states: { + ok: { type: "final" }, + error: { type: "final" }, + }, + }, + }, + }, + { + services: { + loadStarterTemplates: ({ organizationId }) => + getTemplateExamples(organizationId), + }, + actions: { + assignError: assign({ + error: (_, { data }) => data, + }), + assignStarterTemplates: assign({ + starterTemplates: (_, { data }) => data, + }), + }, + }, +) From c3b2e67989c0cfa825021fb71e644944d91e7a5d Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 8 Dec 2022 15:08:25 +0000 Subject: [PATCH 09/53] Add styles to the template card --- .../StarterTemplatesPageView.tsx | 43 +++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx index f5e1409fac17b..f79396784d876 100644 --- a/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx +++ b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx @@ -1,3 +1,4 @@ +import { makeStyles } from "@material-ui/core/styles" import { AlertBanner } from "components/AlertBanner/AlertBanner" import { Maybe } from "components/Conditionals/Maybe" import { Margins } from "components/Margins/Margins" @@ -18,6 +19,7 @@ export const StarterTemplatesPageView: FC = ({ context, }) => { const { t } = useTranslation("starterTemplatesPage") + const styles = useStyles() return ( @@ -30,10 +32,43 @@ export const StarterTemplatesPageView: FC = ({ - {context.starterTemplates && - context.starterTemplates.map((example) => ( -
{example.name}
- ))} +
+ {context.starterTemplates && + context.starterTemplates.map((example) => ( +
+ {example.name} + + {example.description} + +
+ ))} +
) } + +const useStyles = makeStyles((theme) => ({ + templates: { + display: "grid", + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + gap: theme.spacing(2), + }, + template: { + padding: theme.spacing(2), + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + background: theme.palette.background.paper, + display: "flex", + flexDirection: "column", + gap: theme.spacing(0.5), + }, + + templateName: { + fontSize: theme.spacing(2), + }, + + templateDescription: { + fontSize: theme.spacing(1.75), + color: theme.palette.text.secondary, + }, +})) From c1448660f23eaa474eaa93ec7381344990622b10 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 8 Dec 2022 15:12:19 +0000 Subject: [PATCH 10/53] Mock entity and handler --- site/src/testHelpers/entities.ts | 18 ++++++++++++++++++ site/src/testHelpers/handlers.ts | 10 ++++++++++ 2 files changed, 28 insertions(+) diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 725383b3f3c98..ef36181611758 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1063,3 +1063,21 @@ export const MockTemplateACLEmpty: TypesGen.TemplateACL = { group: [], users: [], } + +export const MockTemplateExample: TypesGen.TemplateExample = { + id: "aws-ecs-container", + url: "https://github.com/coder/coder/tree/main/examples/templates/aws-ecs-container", + name: "Develop in an ECS-hosted container", + description: "Get started with Linux development on AWS ECS.", + markdown: + "\n# aws-ecs\n\nThis is a sample template for running a Coder workspace on ECS. It assumes there\nis a pre-existing ECS cluster with EC2-based compute to host the workspace.\n\n## Architecture\n\nThis workspace is built using the following AWS resources:\n\n- Task definition - the container definition, includes the image, command, volume(s)\n- ECS service - manages the task definition\n\n## code-server\n\n`code-server` is installed via the `startup_script` argument in the `coder_agent`\nresource block. The `coder_app` resource is defined to access `code-server` through\nthe dashboard UI over `localhost:13337`.\n", +} + +export const MockTemplateExample2: TypesGen.TemplateExample = { + id: "aws-linux", + url: "https://github.com/coder/coder/tree/main/examples/templates/aws-linux", + name: "Develop in Linux on AWS EC2", + description: "Get started with Linux development on AWS EC2.", + markdown: + '\n# aws-linux\n\nTo get started, run `coder templates init`. When prompted, select this template.\nFollow the on-screen instructions to proceed.\n\n## Authentication\n\nThis template assumes that coderd is run in an environment that is authenticated\nwith AWS. For example, run `aws configure import` to import credentials on the\nsystem and user running coderd. For other ways to authenticate [consult the\nTerraform docs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#authentication-and-configuration).\n\n## Required permissions / policy\n\nThe following sample policy allows Coder to create EC2 instances and modify\ninstances provisioned by Coder:\n\n```json\n{\n "Version": "2012-10-17",\n "Statement": [\n {\n "Sid": "VisualEditor0",\n "Effect": "Allow",\n "Action": [\n "ec2:GetDefaultCreditSpecification",\n "ec2:DescribeIamInstanceProfileAssociations",\n "ec2:DescribeTags",\n "ec2:CreateTags",\n "ec2:RunInstances",\n "ec2:DescribeInstanceCreditSpecifications",\n "ec2:DescribeImages",\n "ec2:ModifyDefaultCreditSpecification",\n "ec2:DescribeVolumes"\n ],\n "Resource": "*"\n },\n {\n "Sid": "CoderResources",\n "Effect": "Allow",\n "Action": [\n "ec2:DescribeInstances",\n "ec2:DescribeInstanceAttribute",\n "ec2:UnmonitorInstances",\n "ec2:TerminateInstances",\n "ec2:StartInstances",\n "ec2:StopInstances",\n "ec2:DeleteTags",\n "ec2:MonitorInstances",\n "ec2:CreateTags",\n "ec2:RunInstances",\n "ec2:ModifyInstanceAttribute",\n "ec2:ModifyInstanceCreditSpecification"\n ],\n "Resource": "arn:aws:ec2:*:*:instance/*",\n "Condition": {\n "StringEquals": {\n "aws:ResourceTag/Coder_Provisioned": "true"\n }\n }\n }\n ]\n}\n```\n\n## code-server\n\n`code-server` is installed via the `startup_script` argument in the `coder_agent`\nresource block. The `coder_app` resource is defined to access `code-server` through\nthe dashboard UI over `localhost:13337`.\n', +} diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index 3c44d2ba942f4..c3b545abe168b 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -266,4 +266,14 @@ export const handlers = [ rest.get("/api/v2/workspace-quota/:userId", (req, res, ctx) => { return res(ctx.status(200), ctx.json(MockWorkspaceQuota)) }), + + rest.get( + "api/v2/organizations/:organizationId/templates/examples", + (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json([M.MockTemplateExample, M.MockTemplateExample2]), + ) + }, + ), ] From c133cc31e0fb20369d797d268b8ea1e63b4e17d8 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 8 Dec 2022 15:19:59 +0000 Subject: [PATCH 11/53] Add storybook --- .../StarterTemplatesPageView.stories.tsx | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.stories.tsx diff --git a/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.stories.tsx b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.stories.tsx new file mode 100644 index 0000000000000..e0faa11de3a07 --- /dev/null +++ b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.stories.tsx @@ -0,0 +1,40 @@ +import { Story } from "@storybook/react" +import { + makeMockApiError, + MockOrganization, + MockTemplateExample, + MockTemplateExample2, +} from "testHelpers/entities" +import { + StarterTemplatesPageView, + StarterTemplatesPageViewProps, +} from "./StarterTemplatesPageView" + +export default { + title: "pages/StarterTemplatesPageView", + component: StarterTemplatesPageView, +} + +const Template: Story = (args) => ( + +) + +export const Default = Template.bind({}) +Default.args = { + context: { + organizationId: MockOrganization.id, + error: undefined, + starterTemplates: [MockTemplateExample, MockTemplateExample2], + }, +} + +export const Error = Template.bind({}) +Error.args = { + context: { + organizationId: MockOrganization.id, + error: makeMockApiError({ + message: "Error on loading the template examples", + }), + starterTemplates: undefined, + }, +} From e7aec299e034a03da84b24cd5bc1f057e6c24ee3 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 8 Dec 2022 15:23:52 +0000 Subject: [PATCH 12/53] Add loader --- .../pages/StarterTemplatesPage/StarterTemplatesPageView.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx index f79396784d876..fd60798cc0304 100644 --- a/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx +++ b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx @@ -1,6 +1,7 @@ import { makeStyles } from "@material-ui/core/styles" import { AlertBanner } from "components/AlertBanner/AlertBanner" import { Maybe } from "components/Conditionals/Maybe" +import { Loader } from "components/Loader/Loader" import { Margins } from "components/Margins/Margins" import { PageHeader, @@ -32,6 +33,10 @@ export const StarterTemplatesPageView: FC = ({ + + + +
{context.starterTemplates && context.starterTemplates.map((example) => ( From 30628146b3e2f179c9bc1d0873cf4d212cf92eb1 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 8 Dec 2022 17:05:33 +0000 Subject: [PATCH 13/53] Add tests --- .../StarterTemplatesPage.test.tsx | 20 +++++++++++++++++++ site/src/testHelpers/handlers.ts | 19 +++++++++--------- 2 files changed, 29 insertions(+), 10 deletions(-) create mode 100644 site/src/pages/StarterTemplatesPage/StarterTemplatesPage.test.tsx diff --git a/site/src/pages/StarterTemplatesPage/StarterTemplatesPage.test.tsx b/site/src/pages/StarterTemplatesPage/StarterTemplatesPage.test.tsx new file mode 100644 index 0000000000000..63d5ed7ce18ad --- /dev/null +++ b/site/src/pages/StarterTemplatesPage/StarterTemplatesPage.test.tsx @@ -0,0 +1,20 @@ +import { screen } from "@testing-library/react" +import { + MockTemplateExample, + MockTemplateExample2, + renderWithAuth, + waitForLoaderToBeRemoved, +} from "testHelpers/renderHelpers" +import StarterTemplatesPage from "./StarterTemplatesPage" + +describe("StarterTemplatesPage", () => { + it("shows the starter template", async () => { + renderWithAuth(, { + route: `/starter-templates`, + path: "/starter-templates", + }) + await waitForLoaderToBeRemoved() + expect(screen.getByText(MockTemplateExample.name)).toBeInTheDocument() + expect(screen.getByText(MockTemplateExample2.name)).toBeInTheDocument() + }) +}) diff --git a/site/src/testHelpers/handlers.ts b/site/src/testHelpers/handlers.ts index c3b545abe168b..a3c251f90a938 100644 --- a/site/src/testHelpers/handlers.ts +++ b/site/src/testHelpers/handlers.ts @@ -24,6 +24,15 @@ export const handlers = [ rest.get("/api/v2/organizations/:organizationId", async (req, res, ctx) => { return res(ctx.status(200), ctx.json(M.MockOrganization)) }), + rest.get( + "api/v2/organizations/:organizationId/templates/examples", + (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json([M.MockTemplateExample, M.MockTemplateExample2]), + ) + }, + ), rest.get( "/api/v2/organizations/:organizationId/templates/:templateId", async (req, res, ctx) => { @@ -266,14 +275,4 @@ export const handlers = [ rest.get("/api/v2/workspace-quota/:userId", (req, res, ctx) => { return res(ctx.status(200), ctx.json(MockWorkspaceQuota)) }), - - rest.get( - "api/v2/organizations/:organizationId/templates/examples", - (req, res, ctx) => { - return res( - ctx.status(200), - ctx.json([M.MockTemplateExample, M.MockTemplateExample2]), - ) - }, - ), ] From d7455c1fd53a962e501b9d758f8308e1b810d650 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 8 Dec 2022 17:42:31 +0000 Subject: [PATCH 14/53] Add basic page to starter template --- site/src/AppRouter.tsx | 10 +++ site/src/components/Markdown/Markdown.tsx | 2 +- .../StarterTemplatePage.tsx | 31 ++++++++ .../StarterTemplatePageView.tsx | 57 +++++++++++++++ .../StarterTemplatesPageView.tsx | 12 +++- .../starterTemplateXService.ts | 71 +++++++++++++++++++ 6 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 site/src/pages/StarterTemplatePage/StarterTemplatePage.tsx create mode 100644 site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx create mode 100644 site/src/xServices/starterTemplates/starterTemplateXService.ts diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index d6d76230492a4..d5726254b1462 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -22,6 +22,7 @@ import { AuthAndFrame } from "./components/AuthAndFrame/AuthAndFrame" import { RequireAuth } from "./components/RequireAuth/RequireAuth" import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout" import { DeploySettingsLayout } from "components/DeploySettingsLayout/DeploySettingsLayout" +import StarterTemplatePage from "pages/StarterTemplatePage/StarterTemplatePage" // Lazy load pages // - Pages that are secondary, not in the main navigation or not usually accessed @@ -153,6 +154,15 @@ export const AppRouter: FC = () => { } /> + + + + + } + > diff --git a/site/src/components/Markdown/Markdown.tsx b/site/src/components/Markdown/Markdown.tsx index fece615c4af03..b31b9168321cd 100644 --- a/site/src/components/Markdown/Markdown.tsx +++ b/site/src/components/Markdown/Markdown.tsx @@ -146,7 +146,7 @@ const useStyles = makeStyles((theme) => ({ }, codeWithoutLanguage: { - padding: theme.spacing(0.5, 1), + padding: theme.spacing(0.125, 0.5), background: theme.palette.divider, borderRadius: 4, color: theme.palette.text.primary, diff --git a/site/src/pages/StarterTemplatePage/StarterTemplatePage.tsx b/site/src/pages/StarterTemplatePage/StarterTemplatePage.tsx new file mode 100644 index 0000000000000..75f9753da1822 --- /dev/null +++ b/site/src/pages/StarterTemplatePage/StarterTemplatePage.tsx @@ -0,0 +1,31 @@ +import { useMachine } from "@xstate/react" +import { useOrganizationId } from "hooks/useOrganizationId" +import { FC } from "react" +import { Helmet } from "react-helmet-async" +import { useParams } from "react-router-dom" +import { pageTitle } from "util/page" +import { starterTemplateMachine } from "xServices/starterTemplates/starterTemplateXService" +import { StarterTemplatePageView } from "./StarterTemplatePageView" + +const StarterTemplatePage: FC = () => { + const { exampleId } = useParams() as { exampleId: string } + const organizationId = useOrganizationId() + const [state] = useMachine(starterTemplateMachine, { + context: { + organizationId, + exampleId, + }, + }) + + return ( + <> + + Codestin Search App + + + + + ) +} + +export default StarterTemplatePage diff --git a/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx b/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx new file mode 100644 index 0000000000000..6b2d47f6530c1 --- /dev/null +++ b/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx @@ -0,0 +1,57 @@ +import { makeStyles } from "@material-ui/core/styles" +import { Loader } from "components/Loader/Loader" +import { Margins } from "components/Margins/Margins" +import { MemoizedMarkdown } from "components/Markdown/Markdown" +import { + PageHeader, + PageHeaderSubtitle, + PageHeaderTitle, +} from "components/PageHeader/PageHeader" +import { FC } from "react" +import { StarterTemplateContext } from "xServices/starterTemplates/starterTemplateXService" + +export interface StarterTemplatePageViewProps { + context: StarterTemplateContext +} + +export const StarterTemplatePageView: FC = ({ + context, +}) => { + const styles = useStyles() + const { starterTemplate } = context + + if (!starterTemplate) { + return + } + + return ( + + + {starterTemplate.name} + {starterTemplate.description} + + +
+
+ {starterTemplate.markdown} +
+
+
+ ) +} + +export const useStyles = makeStyles((theme) => { + return { + 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", + }, + } +}) diff --git a/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx index fd60798cc0304..19176915f3663 100644 --- a/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx +++ b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx @@ -10,6 +10,7 @@ import { } from "components/PageHeader/PageHeader" import { FC } from "react" import { useTranslation } from "react-i18next" +import { Link } from "react-router-dom" import { StarterTemplatesContext } from "xServices/starterTemplates/starterTemplatesXService" export interface StarterTemplatesPageViewProps { @@ -40,12 +41,12 @@ export const StarterTemplatesPageView: FC = ({
{context.starterTemplates && context.starterTemplates.map((example) => ( -
+ {example.name} {example.description} -
+ ))}
@@ -58,6 +59,7 @@ const useStyles = makeStyles((theme) => ({ gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: theme.spacing(2), }, + template: { padding: theme.spacing(2), border: `1px solid ${theme.palette.divider}`, @@ -66,6 +68,12 @@ const useStyles = makeStyles((theme) => ({ display: "flex", flexDirection: "column", gap: theme.spacing(0.5), + textDecoration: "none", + color: "inherit", + + "&:hover": { + backgroundColor: theme.palette.background.paperLight, + }, }, templateName: { diff --git a/site/src/xServices/starterTemplates/starterTemplateXService.ts b/site/src/xServices/starterTemplates/starterTemplateXService.ts new file mode 100644 index 0000000000000..077b5b8d4fec9 --- /dev/null +++ b/site/src/xServices/starterTemplates/starterTemplateXService.ts @@ -0,0 +1,71 @@ +import { getTemplateExamples } from "api/api" +import { TemplateExample } from "api/typesGenerated" +import { assign, createMachine } from "xstate" + +export interface StarterTemplateContext { + organizationId: string + exampleId: string + starterTemplate?: TemplateExample + error?: unknown +} + +export const starterTemplateMachine = createMachine( + { + id: "starterTemplate", + predictableActionArguments: true, + schema: { + context: {} as StarterTemplateContext, + services: {} as { + loadStarterTemplate: { + data: TemplateExample + } + }, + }, + tsTypes: {} as import("./starterTemplateXService.typegen").Typegen0, + initial: "loading", + states: { + loading: { + invoke: { + src: "loadStarterTemplate", + onDone: { + actions: ["assignStarterTemplate"], + target: "idle.ok", + }, + onError: { + actions: ["assignError"], + target: "idle.error", + }, + }, + }, + idle: { + initial: "ok", + states: { + ok: { type: "final" }, + error: { type: "final" }, + }, + }, + }, + }, + { + services: { + loadStarterTemplate: async ({ organizationId, exampleId }) => { + const examples = await getTemplateExamples(organizationId) + const starterTemplate = examples.find( + (example) => example.id === exampleId, + ) + if (!starterTemplate) { + throw new Error(`Example ${exampleId} not found.`) + } + return starterTemplate + }, + }, + actions: { + assignError: assign({ + error: (_, { data }) => data, + }), + assignStarterTemplate: assign({ + starterTemplate: (_, { data }) => data, + }), + }, + }, +) From 193c93022f1863528e00e4d8ffc391a13ddcad55 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 8 Dec 2022 17:48:16 +0000 Subject: [PATCH 15/53] Add buttons --- .../StarterTemplatePageView.tsx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx b/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx index 6b2d47f6530c1..e6c434be1d52d 100644 --- a/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx +++ b/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx @@ -1,3 +1,4 @@ +import Button from "@material-ui/core/Button" import { makeStyles } from "@material-ui/core/styles" import { Loader } from "components/Loader/Loader" import { Margins } from "components/Margins/Margins" @@ -9,6 +10,8 @@ import { } from "components/PageHeader/PageHeader" import { FC } from "react" import { StarterTemplateContext } from "xServices/starterTemplates/starterTemplateXService" +import EyeIcon from "@material-ui/icons/VisibilityOutlined" +import PlusIcon from "@material-ui/icons/AddOutlined" export interface StarterTemplatePageViewProps { context: StarterTemplateContext @@ -26,7 +29,22 @@ export const StarterTemplatePageView: FC = ({ return ( - + + + + + } + > {starterTemplate.name} {starterTemplate.description} From 9a5b1de994d65c7986f4a0bc1011fccce966553d Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 8 Dec 2022 17:49:50 +0000 Subject: [PATCH 16/53] Fix title --- site/src/pages/StarterTemplatePage/StarterTemplatePage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/site/src/pages/StarterTemplatePage/StarterTemplatePage.tsx b/site/src/pages/StarterTemplatePage/StarterTemplatePage.tsx index 75f9753da1822..b0ec53fa3ab8d 100644 --- a/site/src/pages/StarterTemplatePage/StarterTemplatePage.tsx +++ b/site/src/pages/StarterTemplatePage/StarterTemplatePage.tsx @@ -20,7 +20,9 @@ const StarterTemplatePage: FC = () => { return ( <> - Codestin Search App + Codestin Search App From c820f4b6a5b29cd9d4dbb38b54b18f41e8da8769 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 8 Dec 2022 17:53:42 +0000 Subject: [PATCH 17/53] Addd storybook --- .../StarterTemplatePageView.stories.tsx | 41 +++++++++++++++++++ .../StarterTemplatePageView.tsx | 9 ++++ 2 files changed, 50 insertions(+) create mode 100644 site/src/pages/StarterTemplatePage/StarterTemplatePageView.stories.tsx diff --git a/site/src/pages/StarterTemplatePage/StarterTemplatePageView.stories.tsx b/site/src/pages/StarterTemplatePage/StarterTemplatePageView.stories.tsx new file mode 100644 index 0000000000000..fc23097efb5e4 --- /dev/null +++ b/site/src/pages/StarterTemplatePage/StarterTemplatePageView.stories.tsx @@ -0,0 +1,41 @@ +import { Story } from "@storybook/react" +import { + makeMockApiError, + MockOrganization, + MockTemplateExample, +} from "testHelpers/entities" +import { + StarterTemplatePageView, + StarterTemplatePageViewProps, +} from "./StarterTemplatePageView" + +export default { + title: "pages/StarterTemplatePageView", + component: StarterTemplatePageView, +} + +const Template: Story = (args) => ( + +) + +export const Default = Template.bind({}) +Default.args = { + context: { + exampleId: MockTemplateExample.id, + organizationId: MockOrganization.id, + error: undefined, + starterTemplate: MockTemplateExample, + }, +} + +export const Error = Template.bind({}) +Error.args = { + context: { + exampleId: MockTemplateExample.id, + organizationId: MockOrganization.id, + error: makeMockApiError({ + message: `Example ${MockTemplateExample.id} not found.`, + }), + starterTemplate: undefined, + }, +} diff --git a/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx b/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx index e6c434be1d52d..f2aab13b5a922 100644 --- a/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx +++ b/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx @@ -12,6 +12,7 @@ import { FC } from "react" import { StarterTemplateContext } from "xServices/starterTemplates/starterTemplateXService" import EyeIcon from "@material-ui/icons/VisibilityOutlined" import PlusIcon from "@material-ui/icons/AddOutlined" +import { AlertBanner } from "components/AlertBanner/AlertBanner" export interface StarterTemplatePageViewProps { context: StarterTemplateContext @@ -23,6 +24,14 @@ export const StarterTemplatePageView: FC = ({ const styles = useStyles() const { starterTemplate } = context + if (context.error) { + return ( + + + + ) + } + if (!starterTemplate) { return } From 9829ad01ba9ce324b5fb1441368141a6b1f4b742 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 8 Dec 2022 17:59:27 +0000 Subject: [PATCH 18/53] Add test --- .../StarterTemplatePage.test.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 site/src/pages/StarterTemplatePage/StarterTemplatePage.test.tsx diff --git a/site/src/pages/StarterTemplatePage/StarterTemplatePage.test.tsx b/site/src/pages/StarterTemplatePage/StarterTemplatePage.test.tsx new file mode 100644 index 0000000000000..323164440d0d2 --- /dev/null +++ b/site/src/pages/StarterTemplatePage/StarterTemplatePage.test.tsx @@ -0,0 +1,20 @@ +import { screen } from "@testing-library/react" +import { + MockTemplateExample, + renderWithAuth, + waitForLoaderToBeRemoved, +} from "testHelpers/renderHelpers" +import StarterTemplatePage from "./StarterTemplatePage" + +jest.mock("remark-gfm", () => jest.fn()) + +describe("StarterTemplatePage", () => { + it("shows the starter template", async () => { + renderWithAuth(, { + route: `/starter-templates/${MockTemplateExample.id}`, + path: "/starter-templates/:exampleId", + }) + await waitForLoaderToBeRemoved() + expect(screen.getByText(MockTemplateExample.name)).toBeInTheDocument() + }) +}) From c9d1c7acd53490146371077dfe7db128bb01bd08 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Thu, 8 Dec 2022 18:02:14 +0000 Subject: [PATCH 19/53] Use translation --- site/src/i18n/en/index.ts | 2 ++ site/src/i18n/en/starterTemplatePage.json | 6 ++++++ .../pages/StarterTemplatePage/StarterTemplatePageView.tsx | 6 ++++-- 3 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 site/src/i18n/en/starterTemplatePage.json diff --git a/site/src/i18n/en/index.ts b/site/src/i18n/en/index.ts index b9059c827c278..e5eedbfdb32f1 100644 --- a/site/src/i18n/en/index.ts +++ b/site/src/i18n/en/index.ts @@ -13,6 +13,7 @@ import loginPage from "./loginPage.json" import workspaceChangeVersionPage from "./workspaceChangeVersionPage.json" import serviceBannerSettings from "./serviceBannerSettings.json" import starterTemplatesPage from "./starterTemplatesPage.json" +import starterTemplatePage from "./starterTemplatePage.json" export const en = { common, @@ -30,4 +31,5 @@ export const en = { workspaceChangeVersionPage, serviceBannerSettings, starterTemplatesPage, + starterTemplatePage, } diff --git a/site/src/i18n/en/starterTemplatePage.json b/site/src/i18n/en/starterTemplatePage.json new file mode 100644 index 0000000000000..b1a5a894392e2 --- /dev/null +++ b/site/src/i18n/en/starterTemplatePage.json @@ -0,0 +1,6 @@ +{ + "actions": { + "viewSourceCode": "View source code", + "useTemplate": "Use template" + } +} diff --git a/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx b/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx index f2aab13b5a922..4ace643a90435 100644 --- a/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx +++ b/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx @@ -13,6 +13,7 @@ import { StarterTemplateContext } from "xServices/starterTemplates/starterTempla import EyeIcon from "@material-ui/icons/VisibilityOutlined" import PlusIcon from "@material-ui/icons/AddOutlined" import { AlertBanner } from "components/AlertBanner/AlertBanner" +import { useTranslation } from "react-i18next" export interface StarterTemplatePageViewProps { context: StarterTemplateContext @@ -23,6 +24,7 @@ export const StarterTemplatePageView: FC = ({ }) => { const styles = useStyles() const { starterTemplate } = context + const { t } = useTranslation("starterTemplatePage") if (context.error) { return ( @@ -48,9 +50,9 @@ export const StarterTemplatePageView: FC = ({ rel="noreferrer" startIcon={} > - View source code + {t("actions.viewSourceCode")} - + } > From 85266d017679b5fcdfd336e8d836dc75cdc0ebf3 Mon Sep 17 00:00:00 2001 From: Garrett Date: Fri, 9 Dec 2022 17:48:33 +0000 Subject: [PATCH 20/53] add icon and tag parsing --- codersdk/templates.go | 12 +++++++----- examples/examples.go | 30 ++++++++++++++++++++++++++++++ examples/examples_test.go | 1 + site/src/api/typesGenerated.ts | 2 ++ 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/codersdk/templates.go b/codersdk/templates.go index d73570c2da98c..21d0870692d1f 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -83,11 +83,13 @@ type UpdateTemplateMeta struct { } type TemplateExample struct { - ID string `json:"id"` - URL string `json:"url"` - Name string `json:"name"` - Description string `json:"description"` - Markdown string `json:"markdown"` + ID string `json:"id"` + URL string `json:"url"` + Name string `json:"name"` + Description string `json:"description"` + Icon string `json:"icon"` + Tags []string `json:"tags"` + Markdown string `json:"markdown"` } // Template returns a single template. diff --git a/examples/examples.go b/examples/examples.go index 93894fc863fc8..78da2079eed6a 100644 --- a/examples/examples.go +++ b/examples/examples.go @@ -87,11 +87,41 @@ func List() ([]codersdk.TemplateExample, error) { return } + tags := []string{} + tagsRaw, exists := frontMatter.FrontMatter["tags"] + if exists { + tagsI, valid := tagsRaw.([]interface{}) + if !valid { + returnError = xerrors.Errorf("example %q tags isn't a slice: type %T", exampleID, tagsRaw) + return + } + for _, tagI := range tagsI { + tag, valid := tagI.(string) + if !valid { + returnError = xerrors.Errorf("example %q tag isn't a string: type %T", exampleID, tagI) + return + } + tags = append(tags, tag) + } + } + + var icon string + iconRaw, exists := frontMatter.FrontMatter["icon"] + if exists { + icon, valid = iconRaw.(string) + if !valid { + returnError = xerrors.Errorf("example %q icon isn't a string", exampleID) + return + } + } + examples = append(examples, codersdk.TemplateExample{ ID: exampleID, URL: exampleURL, Name: name, Description: description, + Icon: icon, + Tags: tags, Markdown: string(frontMatter.Content), }) } diff --git a/examples/examples_test.go b/examples/examples_test.go index 11854cef4347d..551692aee820f 100644 --- a/examples/examples_test.go +++ b/examples/examples_test.go @@ -27,6 +27,7 @@ func TestTemplate(t *testing.T) { assert.NotEmpty(t, eg.Name, "example name should not be empty") assert.NotEmpty(t, eg.Description, "example description should not be empty") assert.NotEmpty(t, eg.Markdown, "example markdown should not be empty") + assert.NotNil(t, eg.Tags, "example tags should not be nil, should be empty array if no tags") _, err := examples.Archive(eg.ID) assert.NoError(t, err, "error archiving example") }) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 345dc6fa7d819..7d790be5c0b64 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -675,6 +675,8 @@ export interface TemplateExample { readonly url: string readonly name: string readonly description: string + readonly icon: string + readonly tags: string[] readonly markdown: string } From 9f3108f3018a4db615970050d2d831cae97ae1bf Mon Sep 17 00:00:00 2001 From: Garrett Delfosse Date: Fri, 9 Dec 2022 13:01:10 -0500 Subject: [PATCH 21/53] remove extra test work Co-authored-by: Dean Sheather --- coderd/templateversions_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index 3b0418a50202a..b0858556a2eed 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -1056,8 +1056,6 @@ func TestTemplateExamples(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() From 79442ef343bf474ae1fa21508dcb1c7bb949bf33 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Fri, 9 Dec 2022 19:50:13 +0000 Subject: [PATCH 22/53] Add icons to page header --- .../StarterTemplatePageView.tsx | 26 +++++++++++- .../StarterTemplatesPageView.tsx | 38 ++++++++++++++---- site/src/testHelpers/entities.ts | 4 ++ site/static/icon/aws.png | Bin 1828 -> 4283 bytes site/static/icon/azure.png | Bin 1375 -> 2753 bytes site/static/icon/do.png | Bin 1032 -> 2009 bytes site/static/icon/docker.png | Bin 2491 -> 5864 bytes site/static/icon/gcp.png | Bin 1887 -> 4105 bytes site/static/icon/k8s.png | Bin 2451 -> 5274 bytes 9 files changed, 58 insertions(+), 10 deletions(-) diff --git a/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx b/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx index 4ace643a90435..f1e0c1f4533d6 100644 --- a/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx +++ b/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx @@ -14,6 +14,7 @@ import EyeIcon from "@material-ui/icons/VisibilityOutlined" import PlusIcon from "@material-ui/icons/AddOutlined" import { AlertBanner } from "components/AlertBanner/AlertBanner" import { useTranslation } from "react-i18next" +import { Stack } from "components/Stack/Stack" export interface StarterTemplatePageViewProps { context: StarterTemplateContext @@ -56,8 +57,17 @@ export const StarterTemplatePageView: FC = ({ } > - {starterTemplate.name} - {starterTemplate.description} + +
+ +
+
+ {starterTemplate.name} + + {starterTemplate.description} + +
+
@@ -71,6 +81,18 @@ export const StarterTemplatePageView: FC = ({ 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}`, diff --git a/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx index 19176915f3663..a65e517aafdcb 100644 --- a/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx +++ b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx @@ -42,10 +42,15 @@ export const StarterTemplatesPageView: FC = ({ {context.starterTemplates && context.starterTemplates.map((example) => ( - {example.name} - - {example.description} - +
+ +
+
+ {example.name} + + {example.description} + +
))}
@@ -61,21 +66,38 @@ const useStyles = makeStyles((theme) => ({ }, template: { - padding: theme.spacing(2), border: `1px solid ${theme.palette.divider}`, borderRadius: theme.shape.borderRadius, background: theme.palette.background.paper, - display: "flex", - flexDirection: "column", - gap: theme.spacing(0.5), textDecoration: "none", color: "inherit", + display: "flex", + alignItems: "center", "&:hover": { backgroundColor: theme.palette.background.paperLight, }, }, + templateIcon: { + width: theme.spacing(12), + height: theme.spacing(12), + display: "flex", + alignItems: "center", + justifyContent: "center", + + "& img": { + height: theme.spacing(4), + }, + }, + + templateInfo: { + padding: theme.spacing(2, 2, 2, 0), + display: "flex", + flexDirection: "column", + gap: theme.spacing(0.5), + }, + templateName: { fontSize: theme.spacing(2), }, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index a810dad794a31..0b71a44fd41c6 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1078,6 +1078,8 @@ export const MockTemplateExample: TypesGen.TemplateExample = { description: "Get started with Linux development on AWS ECS.", markdown: "\n# aws-ecs\n\nThis is a sample template for running a Coder workspace on ECS. It assumes there\nis a pre-existing ECS cluster with EC2-based compute to host the workspace.\n\n## Architecture\n\nThis workspace is built using the following AWS resources:\n\n- Task definition - the container definition, includes the image, command, volume(s)\n- ECS service - manages the task definition\n\n## code-server\n\n`code-server` is installed via the `startup_script` argument in the `coder_agent`\nresource block. The `coder_app` resource is defined to access `code-server` through\nthe dashboard UI over `localhost:13337`.\n", + icon: "/icon/aws.png", + tags: ["aws", "cloud"], } export const MockTemplateExample2: TypesGen.TemplateExample = { @@ -1087,4 +1089,6 @@ export const MockTemplateExample2: TypesGen.TemplateExample = { description: "Get started with Linux development on AWS EC2.", markdown: '\n# aws-linux\n\nTo get started, run `coder templates init`. When prompted, select this template.\nFollow the on-screen instructions to proceed.\n\n## Authentication\n\nThis template assumes that coderd is run in an environment that is authenticated\nwith AWS. For example, run `aws configure import` to import credentials on the\nsystem and user running coderd. For other ways to authenticate [consult the\nTerraform docs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#authentication-and-configuration).\n\n## Required permissions / policy\n\nThe following sample policy allows Coder to create EC2 instances and modify\ninstances provisioned by Coder:\n\n```json\n{\n "Version": "2012-10-17",\n "Statement": [\n {\n "Sid": "VisualEditor0",\n "Effect": "Allow",\n "Action": [\n "ec2:GetDefaultCreditSpecification",\n "ec2:DescribeIamInstanceProfileAssociations",\n "ec2:DescribeTags",\n "ec2:CreateTags",\n "ec2:RunInstances",\n "ec2:DescribeInstanceCreditSpecifications",\n "ec2:DescribeImages",\n "ec2:ModifyDefaultCreditSpecification",\n "ec2:DescribeVolumes"\n ],\n "Resource": "*"\n },\n {\n "Sid": "CoderResources",\n "Effect": "Allow",\n "Action": [\n "ec2:DescribeInstances",\n "ec2:DescribeInstanceAttribute",\n "ec2:UnmonitorInstances",\n "ec2:TerminateInstances",\n "ec2:StartInstances",\n "ec2:StopInstances",\n "ec2:DeleteTags",\n "ec2:MonitorInstances",\n "ec2:CreateTags",\n "ec2:RunInstances",\n "ec2:ModifyInstanceAttribute",\n "ec2:ModifyInstanceCreditSpecification"\n ],\n "Resource": "arn:aws:ec2:*:*:instance/*",\n "Condition": {\n "StringEquals": {\n "aws:ResourceTag/Coder_Provisioned": "true"\n }\n }\n }\n ]\n}\n```\n\n## code-server\n\n`code-server` is installed via the `startup_script` argument in the `coder_agent`\nresource block. The `coder_app` resource is defined to access `code-server` through\nthe dashboard UI over `localhost:13337`.\n', + icon: "/icon/aws.png", + tags: ["aws", "cloud"], } diff --git a/site/static/icon/aws.png b/site/static/icon/aws.png index af2effa289d01627775971e6a339fa8d7c144857..b15292720c423ff07fa302392c3f54757e36e1d7 100644 GIT binary patch literal 4283 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 delta 1819 zcmV+$2juv>A*2pAiBL{Q4GJ0x0000DNk~Le0000v0000e2nGNE07Q~fy8r+H32;bR za{vGf6951U69E94oEVWdAAbh9Nklo@tJT`M&UW14S z-UH4kZz=-j8G1Ro%?~X0u)SE=r~n3f$n6kvj|=`VV0Vyll>sX#1Shi%#WtWxmy+0- z`-R>*(U#(2JHxPXtAC43r0&n51kE1-lpFdrz-gda@XAAR@(18A;1l2hU^H+YFcw${ z6bgfdLT{GfS9#bzZVd7@7nwPty-4u43;s@EGB5-fpzJry{U-w16oQkZfZjpchZ{3} zv*!0v?U(o%LoZ&moz-Q!i+#Bw-qyT`|u3b|*2V%TSA?*sZ%xCEt@$`0{v zRA=nH=M3E((KcT5IB5Ab?;*`Qte;bb?)yUbZo{q**y1rJj=Cwp1PX;9xXm!o0OSGt zff6xTFDG9VZLezH!-6+J^OE%Q3Bc04A2shJ5aBVt1%G-XTUAHmWA2U49Gou{T7c!s z&QPRGU)4UGn@)}sZRwi#p?*FjD21e1cu zedgt)Za<>icvQAQK2+JOgz0J3J{!7+!XU_B1!M{RRM~C@o>);oahQwT3`34{YmWH2E|bYT&$QGr$cKJ|a;Kw8e>o`oTw=-uu6D6A z$IxBk(OZT_kR8bQW48=)XM@<|eaC@}v!_vHXGi?S^E26dxs-b7GA4^IBT~r}Ma?)C ztEkahOKlFnTRVyTX((KJ5W9IU${SE}Ud)$ib?~el&tF+DP@<(aQC~2!;r9pDGWwjl z0b$I1M7#s6Cfg}@vfyR}4f4U$dORcon}4BPpgy7{bg5G-#o@OacnmLR8nw^_+!rKZ zsZPQhoey8^yC;QOZ)!WYT;bgHVDXndhy4QJUEtfmBy=-^FYDpU8dR$>{vqdbS1i>( zkQ(#V!!Ce7K2tFhN7ZIpr?${|x&%qBB_ABR1uHNG<1A8JX|!&Nw*~C$n05)ewSN+a zeibc1^(CMYvk?cu*RdpifuX(F=qpji(*I0qBaMS|Yv@`!j4^VO@&v}&h|i0ffPgP> z^Ur%S-7dThjmDNvg0lH-=Lhcqkb&A>TrurKxNfD3NGcIoR}{_0WbJxctoX4zMdF2t8iJe&L0kHF^SNKaZaDw|002ov JPDHLkV1jI*RyqIx diff --git a/site/static/icon/azure.png b/site/static/icon/azure.png index aba18fe3f5af5616a108c68044c5485d218101a0..1516339df51920292edfbf32fef6c78cfb003dca 100644 GIT binary patch delta 2752 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 delta 1017 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 BwT}P* diff --git a/site/static/icon/gcp.png b/site/static/icon/gcp.png index 9585ea1244aef74735eb43d34cb89cee7e8f7cc1..350bd4881ae4531d1d859cf19807d862d11c6337 100644 GIT binary patch literal 4105 zcmV+k5ccnhP)V0Ob-c35B-0R>5s8tv>ClTirgZ3q_!|@hM(_ zw!2p9KI*z{t+lRfts*{Yfh-Zh3xZqkP^y9frCdU;GnqN(``&%egb2lCl9}_(WFq_? zh9om5Gv}QDdEf87eCG>7FXzb2(Zc{j6Y?#*z$F}}NDmi)Lp{<%380uCVShXR8kdN5 z65${b9*_Y0!WPsvX>XT~ix&sgynGOPAqc&ULuF-sq>O&S7Boo_l@q~*fQTM{lMXB@ z2n0#gR5vuaY<7Zu6-*Gy?|aJAWI`66%*%p1efxe{s-T((iN;L*g{!Qam*q7 zZ7bmfK68URbj z-w$<8|1f##j`G#+638N16>#5_i%JT@&M%`%l|!%Rgn0E%|F&m{9PPTdSFCEi8L~vy z#z2}s%4w=E{7<&Lh0yEyxOgXnLPi9^4I7u`{Teb%RxI$1QKOvZ`oiV$Pk?yHIcjae zaJaP-@q9?Dd@bOfij{80Jxx{w95%G*OI&=uE!Qe5sp50? z(=NsP2%_P_E?f;6Job#9)Pofj1$kKiV9cYhLI%M^u^D3g?S2czn;Pg1B-rh0Z$D{j z1l#79sK7=!SZX*|p&ktyKBo~zDj>V7%H0X^e%yWWPVAg(N&8n%T(`<-3JdfSZfPW71jFDf&H3a>TV;}52_mL3PzK_D_Ec_xuE%J)xmC_5YKm; z=~enTs5ywSXz8ZPe(!@}GA7`WnUjX0@T&QwTmXjg+c~ztcZ7xqTUGvjgKBD;Apl3Y zDikV)E4YZhgj-k_v|n!~#A|ya?)po4-=-zu8^JIc6R>Vp=~4~)PLqL)zq8zAt+xgI zq8VCm8@%DQ7BI+rWfvFsvqRq{pk=PoAtT})2c^}dwVu0qS^hRKNJa!a#wP+SXBP)9 zXMrJ(k8``jNZ-oexWnj4IF-m3CQmwG^?UexK4Xq-^JEc6x_!gv}{ zJ84oLM}RkUddLJP&WGe4Ql1B*C=Q}H2BL%?edB-y9-yQALCF6{7U25Y51Qv?OFW;Q zHy3=E9}Zv5R#OFr-~_*bQ2i4a#JB}EmS1wIOX8`&>8g_-iBUor(JuyaXauZb%^(M` zV0~Ob%b$04Tv<+;$m2&$0yQ~6M?DH+ z0BFf0D(Zf=rn2Y=7{IuI^|L4ZksGuXi64C7@)bB290U9O2C&bL0E9VU*QtFhio}j; z_7}Urg6D-rzk)jNWt^AD{W8w%l}(>zKTxfe8UlggvCtCNR4>W<6&S#%fP1Ho8&VV! z?|B+RqBsuWOOAjust&NwO<0yGgam~WwsJ4=VcFn5hL=JFGR*9|TQ1{*%9Esn$JJn#&dOntIH(_URizaILZpf`*bYEVyO`?!%2vBPYIV5Aa6L_%xtD@8K2zTzj5vmp)A7BI3A&T(x0 zJBq^u$Tc)r+Qr3MSa-9xGCIz$=dqBllK(aq!1E4j$DRIn!8D2JRDHdCWxU&SOH&dszS*8g3csH_cgH11I`A%{oPkqYjdsF!K5&VD zuc>(f!Ot!<>!^P?^^TUY-Pyj|0{Ri1K>86Wu%Q=_5HDt4ypP`B!o!ducsvX{QmmLi zpJZ3tc3VJY$%R~joB_Q6U%XadyzFC~13&3b&S z$^LXJgoe8)M@fkbgaf}6Hq~|aQ+@H`B&TP~^0c(>0cp=fY2C>v_u&28__?SDNeX$vewPoGJZKFk;ly{oo zwu={@9@_&w#H>51zp%Vplv4l9iDDrbf~`ERNR6vmUid~wuXkyIXj@nCDK;V=TV10F z;bQz>?~6{E`*Jez65G#!dO6iwp#;tpA==;3Hn||4PxUf9y77DVav`u(*p9Kqf!G6b zKx%dBMnxCDETn+RCMH&+pk}_DZjn>mf5*zyx&+QN_Z1QKN@W=S#y$Gtp(UsFw0I}8 zX-Vj9^6W1Q+u969(Ee3k)|nekDqwUy3}H7s8Uo@6DQLlJYr32EJ>*ycAGmRx2Y$N< zj^}wS9!`$^1#%4E8sl;OO<~(_g8`7}l4*BFI&4-a6)>;Zx`=yL2Bsy$vqE{RARwWc z@i&Epm28Rcpe5$yqIm6W`F?&apj^x~)8P3cYmjd_pXunOqyl;#jHaN`rv#FyAK>vU z@KgjJ@mKF27H@7Yi`P!c_9yzs~WO8(9o20%bi!k@A+?Jrk}H^;Z~mwE^Y+tI*| zo=++uDoUxj__5#j6Lw91hSQk5{&|Oa5DL5$msYyCqvu8PUus*fIBooQ*ZQw;8dLRM zjBywqy!orVypEnuo(Dp3l;Jrky%GvO1G}?OGU>^c2vGqGk(NY7( z?$qsV*sWp?pUD)Lwm22!VN`TD10Z=G=&8NIJ0#dY53R4kX-T4fxzL|&3;{rYin$xF z?dY}Sl^9FB7S#DsqHK*f(KZ+arx~;Ej$g$Mmx+PHK|dWM-Qmo-qykzoZx?S-2LaI@ zjL>A?LF)|l6ng>FRvbN>#k-xfHgU{{JQq?Ikn0DW!_l9>06Z3c8wNT*sC^#t;1kZ| z)lGv!cAv+%qkcY^0L7~V-z-Rd4CK0_;tXbkkrRm_d@&!A9axmI~GAHx7s!nK2z zU8Sm=OKWP->v%7^dINT&^c?+%5;!|>Z}7Nyw>FK3TGfYt)}P}4KE*ELs>ETV`Fvk; zIXB^Z(Dam6;!0E7Io0aq#cvEEC6VVoUQ-k5^uq35ozhbq`S@RlK%6SxNv|Ys>d4Ry z7azU1`3F(rJ_g8$c#v?9FXRs>cs$uQ-4c+y>CK*}D|swT^Oof{@osGR{Kv#QLnjF2 zQZn7U{(4N>OYL^2V9!zY3}=&REIV~Vy!v4G^Pg4-X+3kO_1!1B+zz_Qg$p1qg|}2# zkivRpzIcUa#7hyBLdgi-{8r_KT~3HfsaJ>7c^nkr-+b5&r{8XQQ_hH&3jQ2SEf)Q; zGTg06TPZhNg>oN6i=OH#9zEkW(?eQvt&WG*ba(rd1>}InlM(NmLc*J!iFjCt&WM)= z(zahhbgr$b#BS%qX-#*)dC=HuxfMJf?mbn+BP`xGj?FkDUWy3Ie%+14zO-R6rnH=9 z+VfQkroacnbEmL~e{L7i7jNMi@lr-w&Q32Ho0-lUqJbtH;Vd{Hv|B12sy<91`1%>~ zQiB5)VcY8?M;m6Oy8vwP^}bj$%=6}cyLsk{1`K6o0oG3hLiqQ7E%hSK<=a*bOnWJ5 zqs7Ouzn?!&YIu+oje#8b%Z{>jBox&C$e$kqIRRUpw5*p%(?4#yH59NsX}|(c=9SCW z?rc#oQQGo)VT=8lC{FiU{J+^Grm5mTGM$w zWsyB?fynt5i}yOBcpy}0|K^$R;4v^r#!aNGvdn58J)j)5T)-uUIox>|k`OaXdS+ih z;g`bv2FP9^MHSoKlLsQZYr0bwONXrbIA6^Ai~5am(4NX7p2r%F;IAWC)S-OD1)12N zObY0)q)&hiLdyNzI)9HXZ6E8m$BVl!G!4vub?5cyLWcQ&ayP1ane@~y00000NkvXX Hu0mjf*AB|5 delta 1879 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 delta 2447 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 Date: Fri, 9 Dec 2022 20:24:23 +0000 Subject: [PATCH 23/53] Add filters --- .../StarterTemplatesPageView.tsx | 115 +++++++++++++++--- 1 file changed, 100 insertions(+), 15 deletions(-) diff --git a/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx index a65e517aafdcb..58df31ce7e4cf 100644 --- a/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx +++ b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx @@ -1,4 +1,5 @@ import { makeStyles } from "@material-ui/core/styles" +import { TemplateExample } from "api/typesGenerated" import { AlertBanner } from "components/AlertBanner/AlertBanner" import { Maybe } from "components/Conditionals/Maybe" import { Loader } from "components/Loader/Loader" @@ -8,11 +9,42 @@ import { PageHeaderSubtitle, PageHeaderTitle, } from "components/PageHeader/PageHeader" +import { Stack } from "components/Stack/Stack" import { FC } from "react" import { useTranslation } from "react-i18next" import { Link } from "react-router-dom" import { StarterTemplatesContext } from "xServices/starterTemplates/starterTemplatesXService" +const getTagLabel = (tag: string) => { + const labelByTag: Record = { + digitalocean: "Digital Ocean", + aws: "AWS", + google: "Google Cloud", + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- this can be undefined + return labelByTag[tag] ?? tag +} + +const getTemplatesByTag = (starterTemplates: TemplateExample[] | undefined) => { + if (!starterTemplates) { + return + } + + const tags: Record = {} + starterTemplates.forEach((template) => { + template.tags.forEach((tag) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- this can be undefined + if (tags[tag]) { + tags[tag].push(template) + } else { + tags[tag] = [template] + } + }) + }) + return tags +} + export interface StarterTemplatesPageViewProps { context: StarterTemplatesContext } @@ -22,6 +54,10 @@ export const StarterTemplatesPageView: FC = ({ }) => { const { t } = useTranslation("starterTemplatesPage") const styles = useStyles() + const templatesByTag = getTemplatesByTag(context.starterTemplates) + const tags = templatesByTag + ? Object.keys(templatesByTag).sort((a, b) => a.localeCompare(b)) + : undefined return ( @@ -38,27 +74,70 @@ export const StarterTemplatesPageView: FC = ({ -
- {context.starterTemplates && - context.starterTemplates.map((example) => ( - -
- -
-
- {example.name} - - {example.description} - -
+ + {templatesByTag && tags && ( + + Filter + + All templates ({context.starterTemplates?.length}) - ))} -
+ {tags.map((tag) => ( + + {getTagLabel(tag)} ({templatesByTag[tag].length}) + + ))} + + )} + +
+ {context.starterTemplates && + context.starterTemplates.map((example) => ( + +
+ +
+
+ {example.name} + + {example.description} + +
+ + ))} +
+
) } const useStyles = makeStyles((theme) => ({ + filter: { + width: theme.spacing(26), + }, + + filterCaption: { + textTransform: "uppercase", + fontWeight: 600, + fontSize: 12, + color: theme.palette.text.secondary, + letterSpacing: "0.1em", + }, + + tagLink: { + color: theme.palette.text.secondary, + textDecoration: "none", + fontSize: 14, + textTransform: "capitalize", + + "&:hover": { + color: theme.palette.text.primary, + }, + }, + templates: { display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", @@ -85,6 +164,7 @@ const useStyles = makeStyles((theme) => ({ display: "flex", alignItems: "center", justifyContent: "center", + flexShrink: 0, "& img": { height: theme.spacing(4), @@ -96,6 +176,7 @@ const useStyles = makeStyles((theme) => ({ display: "flex", flexDirection: "column", gap: theme.spacing(0.5), + overflow: "hidden", }, templateName: { @@ -105,5 +186,9 @@ const useStyles = makeStyles((theme) => ({ templateDescription: { fontSize: theme.spacing(1.75), color: theme.palette.text.secondary, + textOverflow: "ellipsis", + width: "100%", + overflow: "hidden", + whiteSpace: "nowrap", }, })) From efa624cdc77a54e26fd5bb217ed0b52b68cc989f Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Fri, 9 Dec 2022 21:01:51 +0000 Subject: [PATCH 24/53] Improve markdown code --- site/src/components/Markdown/Markdown.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/site/src/components/Markdown/Markdown.tsx b/site/src/components/Markdown/Markdown.tsx index b31b9168321cd..b93970159df30 100644 --- a/site/src/components/Markdown/Markdown.tsx +++ b/site/src/components/Markdown/Markdown.tsx @@ -44,17 +44,21 @@ export const Markdown: FC<{ children: string }> = ({ children }) => { code: ({ node, inline, className, children, ...props }) => { const match = /language-(\w+)/.exec(className || "") + if (match) { + console.log(match[1]) + } + return !inline && match ? ( - {String(children).replace(/\n$/, "")} + {String(children)} ) : ( @@ -134,14 +138,19 @@ const useStyles = makeStyles((theme) => ({ background: theme.palette.background.paperLight, borderRadius: theme.shape.borderRadius, padding: theme.spacing(2, 3), + overflowX: "auto", "& code": { color: theme.palette.text.secondary, }, - "& .key, & .property": { + "& .key, & .property, & .inserted, .keyword": { color: colors.turquoise[7], }, + + "& .deleted": { + color: theme.palette.error.light, + }, }, }, From e05d148038d7cba38a7f0bce88d00edcda25b8e5 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Mon, 12 Dec 2022 14:52:45 +0000 Subject: [PATCH 25/53] Add filter --- site/src/i18n/en/starterTemplatesPage.json | 9 ++- .../StarterTemplatesPageView.stories.tsx | 8 +- .../StarterTemplatesPageView.tsx | 78 ++++++++++--------- site/src/util/starterTemplates.ts | 24 ++++++ .../starterTemplatesXService.ts | 5 +- 5 files changed, 81 insertions(+), 43 deletions(-) create mode 100644 site/src/util/starterTemplates.ts diff --git a/site/src/i18n/en/starterTemplatesPage.json b/site/src/i18n/en/starterTemplatesPage.json index 0fa591386de40..f9abcbae9491d 100644 --- a/site/src/i18n/en/starterTemplatesPage.json +++ b/site/src/i18n/en/starterTemplatesPage.json @@ -1,4 +1,11 @@ { "title": "Starter Templates", - "subtitle": "Pick one of the built-in templates to start using Coder" + "subtitle": "Pick one of the built-in templates to start using Coder", + "filterCaption": "Filter", + "tags": { + "all": "All templates", + "digitalocean": "Digital Ocean", + "aws": "AWS", + "google": "Google Cloud" + } } diff --git a/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.stories.tsx b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.stories.tsx index e0faa11de3a07..bd2e3c06e7904 100644 --- a/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.stories.tsx +++ b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.stories.tsx @@ -5,6 +5,7 @@ import { MockTemplateExample, MockTemplateExample2, } from "testHelpers/entities" +import { getTemplatesByTag } from "util/starterTemplates" import { StarterTemplatesPageView, StarterTemplatesPageViewProps, @@ -24,7 +25,10 @@ Default.args = { context: { organizationId: MockOrganization.id, error: undefined, - starterTemplates: [MockTemplateExample, MockTemplateExample2], + starterTemplatesByTag: getTemplatesByTag([ + MockTemplateExample, + MockTemplateExample2, + ]), }, } @@ -35,6 +39,6 @@ Error.args = { error: makeMockApiError({ message: "Error on loading the template examples", }), - starterTemplates: undefined, + starterTemplatesByTag: undefined, }, } diff --git a/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx index 58df31ce7e4cf..86fe20ed23291 100644 --- a/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx +++ b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx @@ -1,5 +1,4 @@ import { makeStyles } from "@material-ui/core/styles" -import { TemplateExample } from "api/typesGenerated" import { AlertBanner } from "components/AlertBanner/AlertBanner" import { Maybe } from "components/Conditionals/Maybe" import { Loader } from "components/Loader/Loader" @@ -12,39 +11,26 @@ import { import { Stack } from "components/Stack/Stack" import { FC } from "react" import { useTranslation } from "react-i18next" -import { Link } from "react-router-dom" +import { Link, useSearchParams } from "react-router-dom" +import { combineClasses } from "util/combineClasses" import { StarterTemplatesContext } from "xServices/starterTemplates/starterTemplatesXService" -const getTagLabel = (tag: string) => { +const getTagLabel = (tag: string, t: (key: string) => string) => { const labelByTag: Record = { - digitalocean: "Digital Ocean", - aws: "AWS", - google: "Google Cloud", + all: t("tags.all"), + digitalocean: t("tags.digitalocean"), + aws: t("tags.aws"), + google: t("tags.google"), } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- this can be undefined return labelByTag[tag] ?? tag } -const getTemplatesByTag = (starterTemplates: TemplateExample[] | undefined) => { - if (!starterTemplates) { - return - } - - const tags: Record = {} - starterTemplates.forEach((template) => { - template.tags.forEach((tag) => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- this can be undefined - if (tags[tag]) { - tags[tag].push(template) - } else { - tags[tag] = [template] - } - }) - }) - return tags +const selectTags = ({ starterTemplatesByTag }: StarterTemplatesContext) => { + return starterTemplatesByTag + ? Object.keys(starterTemplatesByTag).sort((a, b) => a.localeCompare(b)) + : undefined } - export interface StarterTemplatesPageViewProps { context: StarterTemplatesContext } @@ -53,10 +39,13 @@ export const StarterTemplatesPageView: FC = ({ context, }) => { const { t } = useTranslation("starterTemplatesPage") + const [urlParams] = useSearchParams() const styles = useStyles() - const templatesByTag = getTemplatesByTag(context.starterTemplates) - const tags = templatesByTag - ? Object.keys(templatesByTag).sort((a, b) => a.localeCompare(b)) + const { starterTemplatesByTag } = context + const tags = selectTags(context) + const activeTag = urlParams.get("tag") ?? "all" + const visibleTemplates = starterTemplatesByTag + ? starterTemplatesByTag[activeTag] : undefined return ( @@ -70,28 +59,32 @@ export const StarterTemplatesPageView: FC = ({ - + - {templatesByTag && tags && ( + {starterTemplatesByTag && tags && ( - Filter - - All templates ({context.starterTemplates?.length}) - + {t("filterCaption")} {tags.map((tag) => ( - - {getTagLabel(tag)} ({templatesByTag[tag].length}) + + {getTagLabel(tag, t)} ({starterTemplatesByTag[tag].length}) ))} )}
- {context.starterTemplates && - context.starterTemplates.map((example) => ( + {visibleTemplates && + visibleTemplates.map((example) => ( = ({ const useStyles = makeStyles((theme) => ({ filter: { width: theme.spacing(26), + flexShrink: 0, }, filterCaption: { @@ -138,10 +132,17 @@ const useStyles = makeStyles((theme) => ({ }, }, + tagLinkActive: { + color: theme.palette.text.primary, + fontWeight: 600, + }, + templates: { + flex: "1", display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: theme.spacing(2), + gridAutoRows: "min-content", }, template: { @@ -152,6 +153,7 @@ const useStyles = makeStyles((theme) => ({ color: "inherit", display: "flex", alignItems: "center", + height: "fit-content", "&:hover": { backgroundColor: theme.palette.background.paperLight, diff --git a/site/src/util/starterTemplates.ts b/site/src/util/starterTemplates.ts new file mode 100644 index 0000000000000..628cf21aa8a62 --- /dev/null +++ b/site/src/util/starterTemplates.ts @@ -0,0 +1,24 @@ +import { TemplateExample } from "api/typesGenerated" + +export type StarterTemplatesByTag = Record + +export const getTemplatesByTag = ( + templates: TemplateExample[], +): StarterTemplatesByTag => { + const tags: StarterTemplatesByTag = { + all: templates, + } + + templates.forEach((template) => { + template.tags.forEach((tag) => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- this can be undefined + if (tags[tag]) { + tags[tag].push(template) + } else { + tags[tag] = [template] + } + }) + }) + + return tags +} diff --git a/site/src/xServices/starterTemplates/starterTemplatesXService.ts b/site/src/xServices/starterTemplates/starterTemplatesXService.ts index 653490b45fa37..744f0c5f2ff82 100644 --- a/site/src/xServices/starterTemplates/starterTemplatesXService.ts +++ b/site/src/xServices/starterTemplates/starterTemplatesXService.ts @@ -1,10 +1,11 @@ import { getTemplateExamples } from "api/api" import { TemplateExample } from "api/typesGenerated" +import { getTemplatesByTag, StarterTemplatesByTag } from "util/starterTemplates" import { assign, createMachine } from "xstate" export interface StarterTemplatesContext { organizationId: string - starterTemplates?: TemplateExample[] + starterTemplatesByTag?: StarterTemplatesByTag error?: unknown } @@ -55,7 +56,7 @@ export const starterTemplatesMachine = createMachine( error: (_, { data }) => data, }), assignStarterTemplates: assign({ - starterTemplates: (_, { data }) => data, + starterTemplatesByTag: (_, { data }) => getTemplatesByTag(data), }), }, }, From 4fc75b7c87fd41cbe20792ee81d9319a4a367739 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Mon, 12 Dec 2022 16:45:17 +0000 Subject: [PATCH 26/53] Add basic create template form structure --- site/src/AppRouter.tsx | 12 + site/src/i18n/en/createTemplatePage.json | 3 + site/src/i18n/en/index.ts | 2 + .../CreateTemplatePage/CreateTemplateForm.tsx | 289 ++++++++++++++++++ .../CreateTemplatePage/CreateTemplatePage.tsx | 30 ++ .../CreateWorkspacePage/SelectedTemplate.tsx | 6 +- .../createTemplate/createTemplateXService.ts | 64 ++++ 7 files changed, 403 insertions(+), 3 deletions(-) create mode 100644 site/src/i18n/en/createTemplatePage.json create mode 100644 site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx create mode 100644 site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx create mode 100644 site/src/xServices/createTemplate/createTemplateXService.ts diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index d5726254b1462..5b8eb056d5a1a 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -96,6 +96,9 @@ const TemplateVersionPage = lazy( const StarterTemplatesPage = lazy( () => import("./pages/StarterTemplatesPage/StarterTemplatesPage"), ) +const CreateTemplatePage = lazy( + () => import("./pages/CreateTemplatePage/CreateTemplatePage"), +) export const AppRouter: FC = () => { const xServices = useContext(XServiceContext) @@ -175,6 +178,15 @@ export const AppRouter: FC = () => { } /> + + + + } + /> + void +} + +export const CreateTemplateForm: FC = ({ + initialValues = defaultInitialValues, + starterTemplate, + errors, + isSubmitting, + onCancel, +}) => { + const styles = useStyles() + const formFooterStyles = useFormFooterStyles() + const form = useFormik({ + initialValues, + validationSchema, + onSubmit: () => { + console.log("Submit") + }, + }) + const getFieldHelpers = getFormHelpers(form, errors) + + return ( +
+ + {/* General info */} +
+
+

General info

+

+ The name is used to identify the template on the URL and also API. + It has to be unique across your organization. +

+
+ + + {starterTemplate && } + + + +
+ + {/* Display info */} +
+
+

Display info

+

+ Set the name that you want to use to display your template, a + helpful description and icon. +

+
+ + + + + + + + +
+ + {/* Schedule */} +
+
+

Schedule

+

+ Define when a workspace create from this template is going to + stop. +

+
+ + + + +
+ + {/* Operations */} +
+
+

Operations

+

+ Allow or not users to run specific actions on the workspace. +

+
+ + + + +
+ + +
+
+ ) +} + +const useStyles = makeStyles((theme) => ({ + formSections: { + [theme.breakpoints.down("sm")]: { + gap: theme.spacing(8), + }, + }, + + formSection: { + display: "flex", + alignItems: "flex-start", + gap: theme.spacing(15), + + [theme.breakpoints.down("sm")]: { + flexDirection: "column", + gap: theme.spacing(2), + }, + }, + + formSectionInfo: { + width: 312, + flexShrink: 0, + position: "sticky", + top: theme.spacing(3), + + [theme.breakpoints.down("sm")]: { + width: "100%", + position: "initial", + }, + }, + + formSectionInfoTitle: { + fontSize: 20, + color: theme.palette.text.primary, + fontWeight: 400, + margin: 0, + marginBottom: theme.spacing(1), + }, + + formSectionInfoDescription: { + fontSize: 14, + color: theme.palette.text.secondary, + lineHeight: "160%", + margin: 0, + }, + + formSectionFields: { + width: "100%", + }, + + optionText: { + fontSize: theme.spacing(2), + color: theme.palette.text.primary, + }, + + optionHelperText: { + fontSize: theme.spacing(1.5), + color: theme.palette.text.secondary, + }, +})) + +const useFormFooterStyles = makeStyles((theme) => ({ + button: { + minWidth: theme.spacing(23), + + [theme.breakpoints.down("sm")]: { + width: "100%", + }, + }, + footer: { + display: "flex", + alignItems: "center", + justifyContent: "flex-start", + flexDirection: "row-reverse", + gap: theme.spacing(2), + + [theme.breakpoints.down("sm")]: { + flexDirection: "column", + gap: theme.spacing(1), + }, + }, +})) diff --git a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx new file mode 100644 index 0000000000000..f2741e960b918 --- /dev/null +++ b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx @@ -0,0 +1,30 @@ +import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizontalForm" +import { FC } from "react" +import { Helmet } from "react-helmet-async" +import { useTranslation } from "react-i18next" +import { useNavigate } from "react-router-dom" +import { pageTitle } from "util/page" +import { CreateTemplateForm } from "./CreateTemplateForm" + +const CreateTemplatePage: FC = () => { + const { t } = useTranslation("createTemplatePage") + const navigate = useNavigate() + + const onCancel = () => { + navigate(-1) + } + + return ( + <> + + Codestin Search App + + + + + + + ) +} + +export default CreateTemplatePage diff --git a/site/src/pages/CreateWorkspacePage/SelectedTemplate.tsx b/site/src/pages/CreateWorkspacePage/SelectedTemplate.tsx index 0dcb577cd77c4..295cb34d5e686 100644 --- a/site/src/pages/CreateWorkspacePage/SelectedTemplate.tsx +++ b/site/src/pages/CreateWorkspacePage/SelectedTemplate.tsx @@ -1,12 +1,12 @@ import Avatar from "@material-ui/core/Avatar" import { makeStyles } from "@material-ui/core/styles" -import { Template } from "api/typesGenerated" +import { Template, TemplateExample } from "api/typesGenerated" import { Stack } from "components/Stack/Stack" import React, { FC } from "react" import { firstLetter } from "util/firstLetter" export interface SelectedTemplateProps { - template: Template + template: Template | TemplateExample } export const SelectedTemplate: FC = ({ template }) => { @@ -28,7 +28,7 @@ export const SelectedTemplate: FC = ({ template }) => {
- {template.display_name.length > 0 + {"display_name" in template && template.display_name.length > 0 ? template.display_name : template.name} diff --git a/site/src/xServices/createTemplate/createTemplateXService.ts b/site/src/xServices/createTemplate/createTemplateXService.ts new file mode 100644 index 0000000000000..5d64453df9753 --- /dev/null +++ b/site/src/xServices/createTemplate/createTemplateXService.ts @@ -0,0 +1,64 @@ +import { TemplateExample } from "api/typesGenerated" +import { createMachine } from "xstate" + +interface CreateTemplateContext { + exampleId?: string + error?: unknown +} + +export const createTemplateMachine = createMachine({ + id: "createTemplate", + predictableActionArguments: true, + schema: { + context: {} as CreateTemplateContext, + events: {} as { type: "CREATE"; data: any }, + services: {} as { + loadingStarterTemplate: { + data: TemplateExample + } + }, + }, + tsTypes: {} as import("./createTemplateXService.typegen").Typegen0, + states: { + starting: { + always: [ + { target: "loadingStarterTemplate", cond: "isExampleProvided" }, + { target: "idle" }, + ], + }, + loadingStarterTemplate: { + invoke: { + src: "loadStarterTemplate", + onDone: { + target: "idle", + actions: ["assignStarterTemplate"], + }, + onError: { + target: "idle", + actions: ["assignError"], + }, + }, + }, + idle: { + on: { + CREATE: "creating", + }, + }, + creating: { + invoke: { + src: "createTemplate", + onDone: { + target: "created", + actions: ["onCreated"], + }, + onError: { + target: "idle", + actions: ["assignError"], + }, + }, + }, + created: { + type: "final", + }, + }, +}) From 9805c2f3a64aedc2758acd43f6c2bcc5d075745e Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Mon, 12 Dec 2022 16:52:59 +0000 Subject: [PATCH 27/53] Add translation --- site/src/i18n/en/createTemplatePage.json | 32 ++++++++++++- .../CreateTemplatePage/CreateTemplateForm.tsx | 45 +++++++++++-------- 2 files changed, 57 insertions(+), 20 deletions(-) diff --git a/site/src/i18n/en/createTemplatePage.json b/site/src/i18n/en/createTemplatePage.json index a099d53fd4867..897c1940f5e1c 100644 --- a/site/src/i18n/en/createTemplatePage.json +++ b/site/src/i18n/en/createTemplatePage.json @@ -1,3 +1,33 @@ { - "title": "Create Template" + "title": "Create Template", + "form": { + "generalInfo": { + "title": "General info", + "description": "The name is used to identify the template on the URL and also API. It has to be unique across your organization." + }, + "displayInfo": { + "title": "Display info", + "description": "Set the name that you want to use to display your template, a helpful description and icon." + }, + "schedule": { + "title": "Schedule", + "description": "Define when a workspace create from this template is going to stop." + }, + "operations": { + "title": "Operations", + "description": "Allow or not users to run specific actions on the workspace." + }, + "fields": { + "name": "Name", + "displayName": "Display name", + "description": "Description", + "icon": "Icon", + "autoStop": "Auto-stop default", + "allowUsersToCancel": "Allow users to cancel in-progress workspace jobs" + }, + "helperText": { + "autoStop": "Time in hours", + "allowUsersToCancel": "Not recommended" + } + } } diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index a15ce14cbf0d8..80d8295863a28 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -7,6 +7,7 @@ import { Stack } from "components/Stack/Stack" import { useFormik } from "formik" import { SelectedTemplate } from "pages/CreateWorkspacePage/SelectedTemplate" import { FC } from "react" +import { useTranslation } from "react-i18next" import { nameValidator, getFormHelpers, onChangeTrimmed } from "util/formUtils" import * as Yup from "yup" @@ -62,6 +63,7 @@ export const CreateTemplateForm: FC = ({ }, }) const getFieldHelpers = getFormHelpers(form, errors) + const { t } = useTranslation("createTemplatePage") return (
@@ -69,10 +71,11 @@ export const CreateTemplateForm: FC = ({ {/* General info */}
-

General info

+

+ {t("form.generalInfo.title")} +

- The name is used to identify the template on the URL and also API. - It has to be unique across your organization. + {t("form.generalInfo.description")}

@@ -89,7 +92,7 @@ export const CreateTemplateForm: FC = ({ onChange={onChangeTrimmed(form)} autoFocus fullWidth - label="Name" + label={t("form.fields.name")} variant="outlined" /> @@ -98,10 +101,11 @@ export const CreateTemplateForm: FC = ({ {/* Display info */}
-

Display info

+

+ {t("form.displayInfo.title")} +

- Set the name that you want to use to display your template, a - helpful description and icon. + {t("form.displayInfo.description")}

@@ -111,7 +115,7 @@ export const CreateTemplateForm: FC = ({ disabled={isSubmitting} onChange={onChangeTrimmed(form)} fullWidth - label="Display name" + label={t("form.fields.displayName")} variant="outlined" /> @@ -122,7 +126,7 @@ export const CreateTemplateForm: FC = ({ rows={5} multiline fullWidth - label="Description" + label={t("form.fields.description")} variant="outlined" /> @@ -131,7 +135,7 @@ export const CreateTemplateForm: FC = ({ disabled={isSubmitting} onChange={onChangeTrimmed(form)} fullWidth - label="Icon" + label={t("form.fields.icon")} variant="outlined" /> @@ -140,10 +144,11 @@ export const CreateTemplateForm: FC = ({ {/* Schedule */}
-

Schedule

+

+ {t("form.schedule.title")} +

- Define when a workspace create from this template is going to - stop. + {t("form.schedule.description")}

@@ -153,10 +158,10 @@ export const CreateTemplateForm: FC = ({ disabled={isSubmitting} onChange={onChangeTrimmed(form)} fullWidth - label="Auto-stop default" + label={t("form.fields.autoStop")} variant="outlined" type="number" - helperText="Time in hours" + helperText={t("form.helperText.autoStop")} />
@@ -164,9 +169,11 @@ export const CreateTemplateForm: FC = ({ {/* Operations */}
-

Operations

+

+ {t("form.operations.title")} +

- Allow or not users to run specific actions on the workspace. + {t("form.operations.description")}

@@ -184,10 +191,10 @@ export const CreateTemplateForm: FC = ({ - Allow users to cancel in-progress workspace jobs. + {t("form.fields.allowUsersToCancel")} - Not recommended + {t("form.helperText.allowUsersToCancel")} From 439ec15feedd0495a0ddbc4727ace51e2d244e31 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Mon, 12 Dec 2022 17:04:14 +0000 Subject: [PATCH 28/53] Add services and actions into machine --- .../CreateTemplatePage/CreateTemplatePage.tsx | 24 ++- .../createTemplate/createTemplateXService.ts | 139 ++++++++++++------ 2 files changed, 112 insertions(+), 51 deletions(-) diff --git a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx index f2741e960b918..d606add657c52 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx @@ -1,14 +1,31 @@ +import { useMachine } from "@xstate/react" import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizontalForm" +import { Loader } from "components/Loader/Loader" +import { useOrganizationId } from "hooks/useOrganizationId" import { FC } from "react" import { Helmet } from "react-helmet-async" import { useTranslation } from "react-i18next" -import { useNavigate } from "react-router-dom" +import { useNavigate, useSearchParams } from "react-router-dom" import { pageTitle } from "util/page" +import { createTemplateMachine } from "xServices/createTemplate/createTemplateXService" import { CreateTemplateForm } from "./CreateTemplateForm" const CreateTemplatePage: FC = () => { const { t } = useTranslation("createTemplatePage") const navigate = useNavigate() + const organizationId = useOrganizationId() + const [searchParams] = useSearchParams() + const [state] = useMachine(createTemplateMachine, { + context: { + organizationId, + exampleId: searchParams.get("exampleId"), + }, + actions: { + onCreate: () => { + console.log("CREATE!") + }, + }, + }) const onCancel = () => { navigate(-1) @@ -21,7 +38,10 @@ const CreateTemplatePage: FC = () => { - + {state.hasTag("loading") && } + {state.matches("idle") && ( + + )} ) diff --git a/site/src/xServices/createTemplate/createTemplateXService.ts b/site/src/xServices/createTemplate/createTemplateXService.ts index 5d64453df9753..2a349c4a779d5 100644 --- a/site/src/xServices/createTemplate/createTemplateXService.ts +++ b/site/src/xServices/createTemplate/createTemplateXService.ts @@ -1,64 +1,105 @@ -import { TemplateExample } from "api/typesGenerated" -import { createMachine } from "xstate" +import { getTemplateExamples } from "api/api" +import { Template, TemplateExample } from "api/typesGenerated" +import { MockTemplate } from "testHelpers/entities" +import { assign, createMachine } from "xstate" interface CreateTemplateContext { - exampleId?: string + organizationId: string + // It can be null because it is being passed from query string + exampleId?: string | null error?: unknown + starterTemplate?: TemplateExample } -export const createTemplateMachine = createMachine({ - id: "createTemplate", - predictableActionArguments: true, - schema: { - context: {} as CreateTemplateContext, - events: {} as { type: "CREATE"; data: any }, - services: {} as { - loadingStarterTemplate: { - data: TemplateExample - } - }, - }, - tsTypes: {} as import("./createTemplateXService.typegen").Typegen0, - states: { - starting: { - always: [ - { target: "loadingStarterTemplate", cond: "isExampleProvided" }, - { target: "idle" }, - ], +export const createTemplateMachine = createMachine( + { + id: "createTemplate", + predictableActionArguments: true, + schema: { + context: {} as CreateTemplateContext, + events: {} as { type: "CREATE"; data: any }, + services: {} as { + loadingStarterTemplate: { + data: TemplateExample + } + createTemplate: { + data: Template + } + }, }, - loadingStarterTemplate: { - invoke: { - src: "loadStarterTemplate", - onDone: { - target: "idle", - actions: ["assignStarterTemplate"], + tsTypes: {} as import("./createTemplateXService.typegen").Typegen0, + initial: "starting", + states: { + starting: { + always: [ + { target: "loadingStarterTemplate", cond: "isExampleProvided" }, + { target: "idle" }, + ], + tags: ["loading"], + }, + loadingStarterTemplate: { + invoke: { + src: "loadStarterTemplate", + onDone: { + target: "idle", + actions: ["assignStarterTemplate"], + }, + onError: { + target: "idle", + actions: ["assignError"], + }, }, - onError: { - target: "idle", - actions: ["assignError"], + tags: ["loading"], + }, + idle: { + on: { + CREATE: "creating", }, }, - }, - idle: { - on: { - CREATE: "creating", + creating: { + invoke: { + src: "createTemplate", + onDone: { + target: "created", + actions: ["onCreate"], + }, + onError: { + target: "idle", + actions: ["assignError"], + }, + }, + }, + created: { + type: "final", }, }, - creating: { - invoke: { - src: "createTemplate", - onDone: { - target: "created", - actions: ["onCreated"], - }, - onError: { - target: "idle", - actions: ["assignError"], - }, + }, + { + services: { + loadStarterTemplate: async ({ organizationId, exampleId }) => { + if (!exampleId) { + throw new Error(`Example ID is not defined.`) + } + const examples = await getTemplateExamples(organizationId) + const starterTemplate = examples.find( + (example) => example.id === exampleId, + ) + if (!starterTemplate) { + throw new Error(`Example ${exampleId} not found.`) + } + return starterTemplate + }, + createTemplate: async () => { + console.log("CALL CREATE TEMPLATE!") + return MockTemplate }, }, - created: { - type: "final", + actions: { + assignError: (_, { data }) => assign({ error: data }), + assignStarterTemplate: (_, { data }) => assign({ starterTemplate: data }), + }, + guards: { + isExampleProvided: ({ exampleId }) => Boolean(exampleId), }, }, -}) +) From ce82a858319a7fe3dce5cd0021dd931aaf2c1aec Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Mon, 12 Dec 2022 17:20:02 +0000 Subject: [PATCH 29/53] Pre-fill info from example data --- .../CreateTemplatePage/CreateTemplateForm.tsx | 26 ++++++++++++------- .../CreateTemplatePage/CreateTemplatePage.tsx | 7 ++++- .../StarterTemplatePageView.tsx | 9 ++++++- .../createTemplate/createTemplateXService.ts | 6 ++--- 4 files changed, 34 insertions(+), 14 deletions(-) diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index 80d8295863a28..1331176625f11 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -39,15 +39,27 @@ const defaultInitialValues: FormValues = { } interface CreateTemplateFormProps { - initialValues?: typeof defaultInitialValues starterTemplate?: TemplateExample errors?: unknown isSubmitting: boolean onCancel: () => void } +const getInitialValues = (starterTemplate?: TemplateExample) => { + if (!starterTemplate) { + return defaultInitialValues + } + + return { + ...defaultInitialValues, + name: starterTemplate.id, + display_name: starterTemplate.name, + icon: starterTemplate.icon, + description: starterTemplate.description, + } +} + export const CreateTemplateForm: FC = ({ - initialValues = defaultInitialValues, starterTemplate, errors, isSubmitting, @@ -55,8 +67,8 @@ export const CreateTemplateForm: FC = ({ }) => { const styles = useStyles() const formFooterStyles = useFormFooterStyles() - const form = useFormik({ - initialValues, + const form = useFormik({ + initialValues: getInitialValues(starterTemplate), validationSchema, onSubmit: () => { console.log("Submit") @@ -79,11 +91,7 @@ export const CreateTemplateForm: FC = ({

- + {starterTemplate && } { }, }, }) + const { starterTemplate } = state.context const onCancel = () => { navigate(-1) @@ -40,7 +41,11 @@ const CreateTemplatePage: FC = () => { {state.hasTag("loading") && } {state.matches("idle") && ( - + )} diff --git a/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx b/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx index f1e0c1f4533d6..3264e5ce12557 100644 --- a/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx +++ b/site/src/pages/StarterTemplatePage/StarterTemplatePageView.tsx @@ -15,6 +15,7 @@ import PlusIcon from "@material-ui/icons/AddOutlined" import { AlertBanner } from "components/AlertBanner/AlertBanner" import { useTranslation } from "react-i18next" import { Stack } from "components/Stack/Stack" +import { Link } from "react-router-dom" export interface StarterTemplatePageViewProps { context: StarterTemplateContext @@ -53,7 +54,13 @@ export const StarterTemplatePageView: FC = ({ > {t("actions.viewSourceCode")} - + } > diff --git a/site/src/xServices/createTemplate/createTemplateXService.ts b/site/src/xServices/createTemplate/createTemplateXService.ts index 2a349c4a779d5..a0a8d8d2763f6 100644 --- a/site/src/xServices/createTemplate/createTemplateXService.ts +++ b/site/src/xServices/createTemplate/createTemplateXService.ts @@ -19,7 +19,7 @@ export const createTemplateMachine = createMachine( context: {} as CreateTemplateContext, events: {} as { type: "CREATE"; data: any }, services: {} as { - loadingStarterTemplate: { + loadStarterTemplate: { data: TemplateExample } createTemplate: { @@ -95,8 +95,8 @@ export const createTemplateMachine = createMachine( }, }, actions: { - assignError: (_, { data }) => assign({ error: data }), - assignStarterTemplate: (_, { data }) => assign({ starterTemplate: data }), + assignError: assign({ error: (_, { data }) => data }), + assignStarterTemplate: assign({ starterTemplate: (_, { data }) => data }), }, guards: { isExampleProvided: ({ exampleId }) => Boolean(exampleId), From 36282fc1408d123a39d123a4192682a06a43b720 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Mon, 12 Dec 2022 19:13:08 +0000 Subject: [PATCH 30/53] Create Icon fiels and remove extra console.log --- site/src/components/IconField/IconField.tsx | 113 ++++++++++++++++++ site/src/components/Markdown/Markdown.tsx | 4 - .../CreateTemplatePage/CreateTemplateForm.tsx | 4 +- 3 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 site/src/components/IconField/IconField.tsx diff --git a/site/src/components/IconField/IconField.tsx b/site/src/components/IconField/IconField.tsx new file mode 100644 index 0000000000000..5646574345e51 --- /dev/null +++ b/site/src/components/IconField/IconField.tsx @@ -0,0 +1,113 @@ +import Button from "@material-ui/core/Button" +import InputAdornment from "@material-ui/core/InputAdornment" +import Popover from "@material-ui/core/Popover" +import TextField, { TextFieldProps } from "@material-ui/core/TextField" +import { OpenDropdown } from "components/DropdownArrows/DropdownArrows" +import { useRef, FC, useState } from "react" +import Picker from "@emoji-mart/react" +import { makeStyles } from "@material-ui/core/styles" +import { colors } from "theme/colors" +import { useTranslation } from "react-i18next" +import data from "@emoji-mart/data/sets/14/twitter.json" + +export const IconField: FC< + TextFieldProps & { onPickEmoji: (value: string) => void } +> = ({ onPickEmoji, ...textFieldProps }) => { + if ( + typeof textFieldProps.value !== "string" && + typeof textFieldProps.value !== "undefined" + ) { + throw new Error(`Invalid icon value "${typeof textFieldProps.value}"`) + } + + const styles = useStyles() + const emojiButtonRef = useRef(null) + const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false) + const { t } = useTranslation("templateSettingsPage") + const hasIcon = textFieldProps.value && textFieldProps.value !== "" + + return ( +
+ + (e.currentTarget.style.display = "none")} + onLoad={(e) => (e.currentTarget.style.display = "inline")} + /> + + ) : undefined, + }} + /> + + + + { + setIsEmojiPickerOpen(false) + }} + > + { + // See: https://github.com/missive/emoji-mart/issues/51#issuecomment-287353222 + const value = `/emojis/${emojiData.unified.replace( + /-fe0f$/, + "", + )}.png` + onPickEmoji(value) + setIsEmojiPickerOpen(false) + }} + /> + +
+ ) +} + +const useStyles = makeStyles((theme) => ({ + "@global": { + "em-emoji-picker": { + "--rgb-background": theme.palette.background.paper, + "--rgb-input": colors.gray[17], + "--rgb-color": colors.gray[4], + }, + }, + adornment: { + width: theme.spacing(3), + height: theme.spacing(3), + display: "flex", + alignItems: "center", + justifyContent: "center", + + "& img": { + maxWidth: "100%", + }, + }, + iconField: { + paddingBottom: theme.spacing(0.5), + }, +})) diff --git a/site/src/components/Markdown/Markdown.tsx b/site/src/components/Markdown/Markdown.tsx index b93970159df30..b608637136a4f 100644 --- a/site/src/components/Markdown/Markdown.tsx +++ b/site/src/components/Markdown/Markdown.tsx @@ -44,10 +44,6 @@ export const Markdown: FC<{ children: string }> = ({ children }) => { code: ({ node, inline, className, children, ...props }) => { const match = /language-(\w+)/.exec(className || "") - if (match) { - console.log(match[1]) - } - return !inline && match ? ( = ({ variant="outlined" /> - form.setFieldValue("icon", value)} />
From 5b013218316e4cb1afde0ca5f166e9cb93c73e01 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 13 Dec 2022 16:35:01 +0000 Subject: [PATCH 31/53] Add basic API for template creation --- site/src/api/api.ts | 87 +++++++++++++++++++ .../CreateTemplatePage/CreateTemplateForm.tsx | 36 +++----- .../CreateTemplatePage/CreateTemplatePage.tsx | 20 +++-- .../createTemplate/createTemplateXService.ts | 26 ++++-- 4 files changed, 136 insertions(+), 33 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index ed3d64b984b9c..a1dc4e6d49393 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -259,6 +259,37 @@ export const getPreviousTemplateVersionByName = async ( } } +export const createTemplateVersion = async ( + organizationId: string, + data: TypesGen.CreateTemplateVersionRequest, +): Promise => { + const response = await axios.post( + `/api/v2/organizations/${organizationId}/templateversions`, + data, + ) + return response.data +} + +export const getTemplateVersionParameters = async ( + versionId: string, +): Promise => { + const response = await axios.get( + `/api/v2/templateversions/${versionId}/parameters`, + ) + return response.data +} + +export const createTemplate = async ( + organizationId: string, + data: TypesGen.CreateTemplateRequest, +): Promise => { + const response = await axios.post( + `/api/v2/organizations/${organizationId}/templates`, + data, + ) + return response.data +} + export const updateTemplateMeta = async ( templateId: string, data: TypesGen.UpdateTemplateMeta, @@ -712,3 +743,59 @@ export const getTemplateExamples = async ( ) return response.data } + +// This function mocks what the CLI is doing +// https://github.com/coder/coder/blob/b6703b11c6578b2f91a310d28b6a7e57f0069be6/cli/templatecreate.go#L169-L170 +export const createValidTemplate = async ( + organizationId: string, + exampleId: string, + // The template_version_id is calculated in the function + data: Omit, +): Promise => { + // First template version is used to generate the parameters and default values + let version = await createTemplateVersion(organizationId, { + storage_method: "file", + example_id: exampleId, + provisioner: "terraform", + tags: {}, + }) + console.log("Create version") + + // Wait the version job to be completed + const waitForJobToBeCompleted = async ( + templateVersion: TypesGen.TemplateVersion, + ) => { + const version = await getTemplateVersion(templateVersion.id) + if (["pending", "running"].includes(version.job.status)) { + await waitForJobToBeCompleted(version) + } + return version + } + version = await waitForJobToBeCompleted(version) + if (version.job.status === "failed") { + throw new Error("Version failed to be created") + } + + console.log("Completed") + // Get schema and create a new template version with the parameters + const schema = await getTemplateVersionSchema(version.id) + version = await createTemplateVersion(organizationId, { + storage_method: "file", + example_id: exampleId, + provisioner: "terraform", + tags: {}, + parameter_values: schema.map((parameter) => ({ + name: parameter.name, + source_scheme: parameter.default_source_scheme, + destination_scheme: parameter.default_destination_scheme, + source_value: parameter.default_source_value, + })), + }) + + // Create template + const template = await createTemplate(organizationId, { + ...data, + template_version_id: version.id, + }) + return template +} diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index 21fcd67322eee..ea66253c86e04 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -10,6 +10,7 @@ import { SelectedTemplate } from "pages/CreateWorkspacePage/SelectedTemplate" import { FC } from "react" import { useTranslation } from "react-i18next" import { nameValidator, getFormHelpers, onChangeTrimmed } from "util/formUtils" +import { CreateTemplateData } from "xServices/createTemplate/createTemplateXService" import * as Yup from "yup" const validationSchema = Yup.object({ @@ -21,16 +22,7 @@ const validationSchema = Yup.object({ allow_user_cancel_workspace_jobs: Yup.boolean(), }) -interface FormValues { - name: string - display_name: string - description: string - icon: string - default_ttl_hours: number - allow_user_cancel_workspace_jobs: boolean -} - -const defaultInitialValues: FormValues = { +const defaultInitialValues: CreateTemplateData = { name: "", display_name: "", description: "", @@ -39,13 +31,6 @@ const defaultInitialValues: FormValues = { allow_user_cancel_workspace_jobs: false, } -interface CreateTemplateFormProps { - starterTemplate?: TemplateExample - errors?: unknown - isSubmitting: boolean - onCancel: () => void -} - const getInitialValues = (starterTemplate?: TemplateExample) => { if (!starterTemplate) { return defaultInitialValues @@ -60,22 +45,29 @@ const getInitialValues = (starterTemplate?: TemplateExample) => { } } +interface CreateTemplateFormProps { + starterTemplate?: TemplateExample + errors?: unknown + isSubmitting: boolean + onCancel: () => void + onSubmit: (data: CreateTemplateData) => void +} + export const CreateTemplateForm: FC = ({ starterTemplate, errors, isSubmitting, onCancel, + onSubmit, }) => { const styles = useStyles() const formFooterStyles = useFormFooterStyles() - const form = useFormik({ + const form = useFormik({ initialValues: getInitialValues(starterTemplate), validationSchema, - onSubmit: () => { - console.log("Submit") - }, + onSubmit, }) - const getFieldHelpers = getFormHelpers(form, errors) + const getFieldHelpers = getFormHelpers(form, errors) const { t } = useTranslation("createTemplatePage") return ( diff --git a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx index 557140f6680b1..953d1ae09fedc 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx @@ -15,18 +15,22 @@ const CreateTemplatePage: FC = () => { const navigate = useNavigate() const organizationId = useOrganizationId() const [searchParams] = useSearchParams() - const [state] = useMachine(createTemplateMachine, { + const [state, send] = useMachine(createTemplateMachine, { context: { organizationId, exampleId: searchParams.get("exampleId"), }, actions: { - onCreate: () => { - console.log("CREATE!") + onCreate: (_, { data }) => { + navigate(`/templates/${data.name}`) }, }, }) const { starterTemplate } = state.context + const shouldDisplayForm = + state.matches("idle") || + state.matches("creating") || + state.matches("created") const onCancel = () => { navigate(-1) @@ -40,11 +44,17 @@ const CreateTemplatePage: FC = () => { {state.hasTag("loading") && } - {state.matches("idle") && ( + {shouldDisplayForm && ( { + send({ + type: "CREATE", + data, + }) + }} /> )} diff --git a/site/src/xServices/createTemplate/createTemplateXService.ts b/site/src/xServices/createTemplate/createTemplateXService.ts index a0a8d8d2763f6..b1be2b42fbea9 100644 --- a/site/src/xServices/createTemplate/createTemplateXService.ts +++ b/site/src/xServices/createTemplate/createTemplateXService.ts @@ -1,6 +1,5 @@ -import { getTemplateExamples } from "api/api" +import { createValidTemplate, getTemplateExamples } from "api/api" import { Template, TemplateExample } from "api/typesGenerated" -import { MockTemplate } from "testHelpers/entities" import { assign, createMachine } from "xstate" interface CreateTemplateContext { @@ -11,13 +10,22 @@ interface CreateTemplateContext { starterTemplate?: TemplateExample } +export interface CreateTemplateData { + name: string + display_name: string + description: string + icon: string + default_ttl_hours: number + allow_user_cancel_workspace_jobs: boolean +} + export const createTemplateMachine = createMachine( { id: "createTemplate", predictableActionArguments: true, schema: { context: {} as CreateTemplateContext, - events: {} as { type: "CREATE"; data: any }, + events: {} as { type: "CREATE"; data: CreateTemplateData }, services: {} as { loadStarterTemplate: { data: TemplateExample @@ -89,9 +97,15 @@ export const createTemplateMachine = createMachine( } return starterTemplate }, - createTemplate: async () => { - console.log("CALL CREATE TEMPLATE!") - return MockTemplate + createTemplate: async ({ organizationId, exampleId }, { data }) => { + if (!exampleId) { + throw new Error("Example ID not provided") + } + return createValidTemplate(organizationId, exampleId, { + ...data, + // hours to milliseconds + default_ttl_ms: data.default_ttl_hours * 60 * 60 * 100, + }) }, }, actions: { From 521803f267b341a6af9154f6e44ce46454253eda Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 13 Dec 2022 18:22:42 +0000 Subject: [PATCH 32/53] Fix create template from example id --- site/src/api/api.ts | 68 ++++++++++--------- .../createTemplate/createTemplateXService.ts | 10 ++- 2 files changed, 44 insertions(+), 34 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index a1dc4e6d49393..6cf38e3ab8438 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -744,7 +744,15 @@ export const getTemplateExamples = async ( return response.data } -// This function mocks what the CLI is doing +// for creating a new template: +// 1. upload template tar or use the example ID +// 2. create template version +// 3. wait for it to complete +// 4. if the job failed with the missing parameter error then: +// a. prompt for params +// b. create template version again with the same file hash +// c. wait for it to complete +// 5.create template with the successful template version ID // https://github.com/coder/coder/blob/b6703b11c6578b2f91a310d28b6a7e57f0069be6/cli/templatecreate.go#L169-L170 export const createValidTemplate = async ( organizationId: string, @@ -752,47 +760,41 @@ export const createValidTemplate = async ( // The template_version_id is calculated in the function data: Omit, ): Promise => { - // First template version is used to generate the parameters and default values + // Step 2. let version = await createTemplateVersion(organizationId, { storage_method: "file", example_id: exampleId, provisioner: "terraform", tags: {}, }) - console.log("Create version") - - // Wait the version job to be completed - const waitForJobToBeCompleted = async ( - templateVersion: TypesGen.TemplateVersion, - ) => { - const version = await getTemplateVersion(templateVersion.id) - if (["pending", "running"].includes(version.job.status)) { - await waitForJobToBeCompleted(version) - } - return version + + // Step 3. + let status = version.job.status + while (["pending", "running"].includes(status)) { + version = await getTemplateVersion(version.id) + status = version.job.status } - version = await waitForJobToBeCompleted(version) - if (version.job.status === "failed") { - throw new Error("Version failed to be created") + if (status === "failed") { + console.error(version.job.error) + throw new Error(version.job.error) } - console.log("Completed") - // Get schema and create a new template version with the parameters - const schema = await getTemplateVersionSchema(version.id) - version = await createTemplateVersion(organizationId, { - storage_method: "file", - example_id: exampleId, - provisioner: "terraform", - tags: {}, - parameter_values: schema.map((parameter) => ({ - name: parameter.name, - source_scheme: parameter.default_source_scheme, - destination_scheme: parameter.default_destination_scheme, - source_value: parameter.default_source_value, - })), - }) - - // Create template + // // Get schema and create a new template version with the parameters + // const schema = await getTemplateVersionSchema(version.id) + // version = await createTemplateVersion(organizationId, { + // storage_method: "file", + // example_id: exampleId, + // provisioner: "terraform", + // tags: {}, + // parameter_values: schema.map((parameter) => ({ + // name: parameter.name, + // source_scheme: parameter.default_source_scheme, + // destination_scheme: parameter.default_destination_scheme, + // source_value: parameter.default_source_value, + // })), + // }) + + // Step 5. const template = await createTemplate(organizationId, { ...data, template_version_id: version.id, diff --git a/site/src/xServices/createTemplate/createTemplateXService.ts b/site/src/xServices/createTemplate/createTemplateXService.ts index b1be2b42fbea9..f3d13cd264153 100644 --- a/site/src/xServices/createTemplate/createTemplateXService.ts +++ b/site/src/xServices/createTemplate/createTemplateXService.ts @@ -1,5 +1,6 @@ import { createValidTemplate, getTemplateExamples } from "api/api" import { Template, TemplateExample } from "api/typesGenerated" +import { displayError } from "components/GlobalSnackbar/utils" import { assign, createMachine } from "xstate" interface CreateTemplateContext { @@ -73,7 +74,7 @@ export const createTemplateMachine = createMachine( }, onError: { target: "idle", - actions: ["assignError"], + actions: ["displayError"], }, }, }, @@ -111,6 +112,13 @@ export const createTemplateMachine = createMachine( actions: { assignError: assign({ error: (_, { data }) => data }), assignStarterTemplate: assign({ starterTemplate: (_, { data }) => data }), + displayError: (_, { data }) => { + if (data instanceof Error) { + displayError(data.message) + return + } + console.warn(`data is not an Error`) + }, }, guards: { isExampleProvided: ({ exampleId }) => Boolean(exampleId), From d39cabe6885b1bce6e8307d2a85f2715ffd615c0 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 14 Dec 2022 13:58:41 +0000 Subject: [PATCH 33/53] Show parameters --- site/src/i18n/en/createTemplatePage.json | 4 + .../CreateTemplatePage/CreateTemplateForm.tsx | 45 ++- .../CreateTemplatePage/CreateTemplatePage.tsx | 17 +- .../createTemplate/createTemplateXService.ts | 368 +++++++++++++----- 4 files changed, 319 insertions(+), 115 deletions(-) diff --git a/site/src/i18n/en/createTemplatePage.json b/site/src/i18n/en/createTemplatePage.json index 897c1940f5e1c..1da7058e15de5 100644 --- a/site/src/i18n/en/createTemplatePage.json +++ b/site/src/i18n/en/createTemplatePage.json @@ -17,6 +17,10 @@ "title": "Operations", "description": "Allow or not users to run specific actions on the workspace." }, + "parameters": { + "title": "Template params", + "description": "Those params are provided by your template's Terraform configuration." + }, "fields": { "name": "Name", "displayName": "Display name", diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index ea66253c86e04..6c04448c654d5 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -1,9 +1,10 @@ import Checkbox from "@material-ui/core/Checkbox" import { makeStyles } from "@material-ui/core/styles" import TextField from "@material-ui/core/TextField" -import { TemplateExample } from "api/typesGenerated" +import { ParameterSchema, TemplateExample } from "api/typesGenerated" import { FormFooter } from "components/FormFooter/FormFooter" import { IconField } from "components/IconField/IconField" +import { ParameterInput } from "components/ParameterInput/ParameterInput" import { Stack } from "components/Stack/Stack" import { useFormik } from "formik" import { SelectedTemplate } from "pages/CreateWorkspacePage/SelectedTemplate" @@ -15,11 +16,12 @@ import * as Yup from "yup" const validationSchema = Yup.object({ name: nameValidator("Name"), - displayName: Yup.string().optional(), + display_name: Yup.string().optional(), description: Yup.string().optional(), icon: Yup.string().optional(), default_ttl_hours: Yup.number(), allow_user_cancel_workspace_jobs: Yup.boolean(), + parameter_values_by_name: Yup.object().optional(), }) const defaultInitialValues: CreateTemplateData = { @@ -29,6 +31,7 @@ const defaultInitialValues: CreateTemplateData = { icon: "", default_ttl_hours: 24, allow_user_cancel_workspace_jobs: false, + parameter_values_by_name: undefined, } const getInitialValues = (starterTemplate?: TemplateExample) => { @@ -47,7 +50,8 @@ const getInitialValues = (starterTemplate?: TemplateExample) => { interface CreateTemplateFormProps { starterTemplate?: TemplateExample - errors?: unknown + error?: unknown + parameters?: ParameterSchema[] isSubmitting: boolean onCancel: () => void onSubmit: (data: CreateTemplateData) => void @@ -55,7 +59,8 @@ interface CreateTemplateFormProps { export const CreateTemplateForm: FC = ({ starterTemplate, - errors, + error, + parameters, isSubmitting, onCancel, onSubmit, @@ -67,7 +72,7 @@ export const CreateTemplateForm: FC = ({ validationSchema, onSubmit, }) - const getFieldHelpers = getFormHelpers(form, errors) + const getFieldHelpers = getFormHelpers(form, error) const { t } = useTranslation("createTemplatePage") return ( @@ -204,6 +209,36 @@ export const CreateTemplateForm: FC = ({
+ {/* Parameters */} + {parameters && ( +
+
+

+ {t("form.parameters.title")} +

+

+ {t("form.parameters.description")} +

+
+ + + {parameters.map((schema) => ( + { + await form.setFieldValue( + `parameter_values_by_name.${schema.name}`, + value, + ) + }} + /> + ))} + +
+ )} + { }, }, }) - const { starterTemplate } = state.context - const shouldDisplayForm = - state.matches("idle") || - state.matches("creating") || - state.matches("created") + const { starterTemplate, parameters, error } = state.context + const shouldDisplayForm = !state.hasTag("loading") const onCancel = () => { navigate(-1) @@ -43,11 +41,16 @@ const CreateTemplatePage: FC = () => { - {state.hasTag("loading") && } + + + + {shouldDisplayForm && ( { send({ diff --git a/site/src/xServices/createTemplate/createTemplateXService.ts b/site/src/xServices/createTemplate/createTemplateXService.ts index f3d13cd264153..bba4275b079de 100644 --- a/site/src/xServices/createTemplate/createTemplateXService.ts +++ b/site/src/xServices/createTemplate/createTemplateXService.ts @@ -1,16 +1,20 @@ -import { createValidTemplate, getTemplateExamples } from "api/api" -import { Template, TemplateExample } from "api/typesGenerated" +import { + getTemplateExamples, + createTemplateVersion, + getTemplateVersion, + createTemplate, + getTemplateVersionSchema, +} from "api/api" +import { + CreateTemplateRequest, + ParameterSchema, + Template, + TemplateExample, + TemplateVersion, +} from "api/typesGenerated" import { displayError } from "components/GlobalSnackbar/utils" import { assign, createMachine } from "xstate" -interface CreateTemplateContext { - organizationId: string - // It can be null because it is being passed from query string - exampleId?: string | null - error?: unknown - starterTemplate?: TemplateExample -} - export interface CreateTemplateData { name: string display_name: string @@ -18,110 +22,268 @@ export interface CreateTemplateData { icon: string default_ttl_hours: number allow_user_cancel_workspace_jobs: boolean + parameter_values_by_name?: Record +} +interface CreateTemplateContext { + organizationId: string + error?: unknown + jobError?: string + starterTemplate?: TemplateExample + exampleId?: string | null // It can be null because it is being passed from query string + version?: TemplateVersion + templateData?: CreateTemplateData + parameters?: ParameterSchema[] } -export const createTemplateMachine = createMachine( - { - id: "createTemplate", - predictableActionArguments: true, - schema: { - context: {} as CreateTemplateContext, - events: {} as { type: "CREATE"; data: CreateTemplateData }, - services: {} as { - loadStarterTemplate: { - data: TemplateExample - } - createTemplate: { - data: Template - } - }, - }, - tsTypes: {} as import("./createTemplateXService.typegen").Typegen0, - initial: "starting", - states: { - starting: { - always: [ - { target: "loadingStarterTemplate", cond: "isExampleProvided" }, - { target: "idle" }, - ], - tags: ["loading"], - }, - loadingStarterTemplate: { - invoke: { - src: "loadStarterTemplate", - onDone: { - target: "idle", - actions: ["assignStarterTemplate"], - }, - onError: { - target: "idle", - actions: ["assignError"], - }, +export const createTemplateMachine = + /** @xstate-layout N4IgpgJg5mDOIC5QGMBOYCGAXMAVMAtgA4A22YAdLFhqlgJYB2UAxANoAMAuoqEQPax6Dfo14gAHogCMAVgDMFDgA5ZATnkKALADY9AdgA0IAJ6I1AJmkVlyi-tk79F2bI4c1AX0-G0mHPjEZDhUNHRMrGzSPEggAkIiYrFSCHKKKuqa8roGxmYI+u4UWgoq0lpqOvJO0t6+6OSBpOQUJPwYEBEAymE4qE3BYCwQopRMAG78ANaUfo2EzSFtHd29YP0LgwgT-MjY9KKcXEfi8cIHSaApOhaK+vrZ8mocOnLK+mp5iMrSyhRqpWUHC0Wh++mUdRAcwCmxay06zB6tD6A3ILHWqH4qAoiwAZliCBRoXhYUt2gioEi6OtUThtoxJntEkcTrEzolxNdbhR7o9nq9ZO9PqZzNILMUXtI1NKAfYtPpIcTaWMICQhgBhABKAFEAIK4bWsviCc6iTmIWQWL4IWT3YoKMWuCzuLQWCE+KENGFBFrQiJEr0RABi9FQ1AAaushKJhqMKDsZgH-CSfSE-cwk-tmCGw1hI2GLvTGftDtwjXETRzkog3X8bm5neplBo3EYRalnBR5PoKs8OBYdGoHLJFV6U4tZoGM+moDmI1GLujUJjsXiCZnvRON-6Z3O8wvREXdiXGCzuKdKxdzalbTp-g7dBxpA9KsL8q6tP99DpZFp5FYOEcCxR2TZVtwzAB3DBzmzLEACl+AAI1wfgACEwHVfggjAHAIFjRgxgZaZJ1A0kSKzKAKCgmDZ3gpCUPQzDsNwo8mQuM8YmNBIr2rAp5GsNQSjleQRMAnRlGtOQOH+GUHAsUEQWkV4QPmVNyIYSDoI02jUAQ5C0IwrDSBwyB8MIyZEyVMjwMo6jtKDOj9MYoy1RYnY2NLY5ogvbizV4h4BKEnsRPkMSJPbexrB7aVlEeQppHkd16lItSbKorTg0chjDOY0yMSxHFgnxVBCSs1KZ3SmiHN0+iDKY4y3KIjzTzLc82UvPyrhkW9734x9nyeQdrRE6xLREywNGbHQOAVD0yq3CqiExYgsAABVoDACBMsMWHVAB5AA5XAAEkDoAVUNNquNNS5JBkKw1AoX4e37Ac3VuSTO3lH9bVcD4LFuJLPRShap0omdlTM+MiMsscwIqiGyNYk8OJ8m7rzkb9evKaaBtfa1lDvWx+wSwmny0GbZuS1TQf8Hcwch-LVyK9d5sGNLEbU5HmVaziK1826Ukxu8AT63GXyG9tfmkkKQop5xu1cbwPUYfgIDgcQ2fINGqy6hAAFo30QfXuRlM3zbUIGtZCahkQiHWeL111rTUBKeSUlRvw0AE9BUzd2fhVZkRpMiHc6u6EGqfQno+Qdm2+qoQQJyoKCqexZGkFQnmBP3x3Z+hVTAMPBZkYKKHsbJpsE1xnyNm07VF6RM8xj4c7muHrJnYvryea0qmKc3ynUTOn1z+GwZsvd82jW72UdiP3kk1QnqfVxAPBH43CtjvyonuzMpqpycoayBu94kFHoUzRf3439LB0T7pNUOQ3EqWOXh0MfO4npajLWjatp9HgO1AWGN+x-A3hoMUdhYryU+uKIcP1hz-UBl-XedNpwM1DiA9GvEEqvC7JoWwLhoH6ClJJHQn5XCWx7C4P8P55BoNpuQCAZ89bPkzjYGBVgLBDlihweQkkZqpxeLyX8Vhmy1GVkAA */ + createMachine( + { + id: "createTemplate", + predictableActionArguments: true, + schema: { + context: {} as CreateTemplateContext, + events: {} as { type: "CREATE"; data: CreateTemplateData }, + services: {} as { + loadStarterTemplate: { + data: TemplateExample + } + createFirstVersion: { + data: TemplateVersion + } + waitForJobToBeCompleted: { + data: TemplateVersion + } + loadParameterSchema: { + data: ParameterSchema[] + } + createTemplate: { + data: Template + } }, - tags: ["loading"], }, - idle: { - on: { - CREATE: "creating", + tsTypes: {} as import("./createTemplateXService.typegen").Typegen0, + initial: "starting", + states: { + starting: { + always: [ + { target: "loadingStarterTemplate", cond: "isExampleProvided" }, + { target: "idle" }, + ], + tags: ["loading"], }, - }, - creating: { - invoke: { - src: "createTemplate", - onDone: { - target: "created", - actions: ["onCreate"], + loadingStarterTemplate: { + invoke: { + src: "loadStarterTemplate", + onDone: { + target: "idle", + actions: ["assignStarterTemplate"], + }, + onError: { + target: "idle", + actions: ["assignError"], + }, + }, + tags: ["loading"], + }, + idle: { + on: { + CREATE: { + target: "creating", + actions: ["assignTemplateData"], + }, }, - onError: { - target: "idle", - actions: ["displayError"], + }, + creating: { + initial: "creatingFirstVersion", + states: { + creatingFirstVersion: { + invoke: { + src: "createFirstVersion", + onDone: { + target: "waitingForJobToBeCompleted", + actions: ["assignVersion"], + }, + onError: { + actions: ["assignError"], + target: "#createTemplate.idle", + }, + }, + tags: ["submitting"], + }, + waitingForJobToBeCompleted: { + invoke: { + src: "waitForJobToBeCompleted", + onDone: [ + { + target: "loadingMissingParameters", + cond: "hasMissingParameters", + actions: ["assignVersion"], + }, + { + target: "#createTemplate.idle", + actions: ["displayJobError"], + cond: "hasFailed", + }, + { target: "creatingTemplate", actions: ["assignVersion"] }, + ], + onError: { + target: "#createTemplate.idle", + actions: ["assignError"], + }, + }, + tags: ["submitting"], + }, + loadingMissingParameters: { + invoke: { + src: "loadParameterSchema", + onDone: { + target: "promptParameters", + actions: ["assignParameters"], + }, + onError: { + target: "#createTemplate.idle", + actions: ["assignError"], + }, + }, + tags: ["submitting"], + }, + promptParameters: { + on: { + CREATE: { + target: "creatingTemplate", + actions: ["assignTemplateData"], + }, + }, + }, + creatingTemplate: { + invoke: { + src: "createTemplate", + onDone: { + target: "created", + actions: ["onCreate"], + }, + onError: { + actions: ["assignError"], + target: "#createTemplate.idle", + }, + }, + tags: ["submitting"], + }, + created: { + type: "final", + }, }, }, }, - created: { - type: "final", - }, }, - }, - { - services: { - loadStarterTemplate: async ({ organizationId, exampleId }) => { - if (!exampleId) { - throw new Error(`Example ID is not defined.`) - } - const examples = await getTemplateExamples(organizationId) - const starterTemplate = examples.find( - (example) => example.id === exampleId, - ) - if (!starterTemplate) { - throw new Error(`Example ${exampleId} not found.`) - } - return starterTemplate + { + services: { + loadStarterTemplate: async ({ organizationId, exampleId }) => { + if (!exampleId) { + throw new Error(`Example ID is not defined.`) + } + const examples = await getTemplateExamples(organizationId) + const starterTemplate = examples.find( + (example) => example.id === exampleId, + ) + if (!starterTemplate) { + throw new Error(`Example ${exampleId} not found.`) + } + return starterTemplate + }, + createFirstVersion: async ({ organizationId, exampleId }) => { + if (!exampleId) { + throw new Error("No example ID provided") + } + + return createTemplateVersion(organizationId, { + storage_method: "file", + example_id: exampleId, + provisioner: "terraform", + tags: {}, + }) + }, + waitForJobToBeCompleted: async ({ version }) => { + if (!version) { + throw new Error("Version not defined") + } + + let status = version.job.status + while (["pending", "running"].includes(status)) { + version = await getTemplateVersion(version.id) + status = version.job.status + } + return version + }, + loadParameterSchema: async ({ version }) => { + if (!version) { + throw new Error("Version not defined") + } + + return getTemplateVersionSchema(version.id) + }, + createTemplate: async ({ + organizationId, + version, + templateData, + parameters, + }) => { + if (!version) { + throw new Error("Version not defined") + } + + if (!templateData) { + throw new Error("Template data not defined") + } + + const { + default_ttl_hours, + parameter_values_by_name, + ...safeTemplateData + } = templateData + + // Get parameter values if they are needed/present + const parameterValues: CreateTemplateRequest["parameter_values"] = [] + if (parameters) { + parameters.forEach((schema) => { + const value = parameter_values_by_name?.[schema.name] + parameterValues.push({ + name: schema.name, + source_value: value ?? schema.default_source_value, + destination_scheme: schema.default_destination_scheme, + source_scheme: schema.default_source_scheme, + }) + }) + } + + return createTemplate(organizationId, { + ...safeTemplateData, + default_ttl_ms: templateData.default_ttl_hours * 60 * 60 * 1000, // Convert hours to ms + template_version_id: version.id, + parameter_values: parameterValues, + }) + }, }, - createTemplate: async ({ organizationId, exampleId }, { data }) => { - if (!exampleId) { - throw new Error("Example ID not provided") - } - return createValidTemplate(organizationId, exampleId, { - ...data, - // hours to milliseconds - default_ttl_ms: data.default_ttl_hours * 60 * 60 * 100, - }) + actions: { + assignError: assign({ error: (_, { data }) => data }), + displayJobError: (_, { data }) => { + displayError("Provisioner job failed.", data.job.error) + }, + assignStarterTemplate: assign({ + starterTemplate: (_, { data }) => data, + }), + assignVersion: assign({ version: (_, { data }) => data }), + assignTemplateData: assign({ templateData: (_, { data }) => data }), + assignParameters: assign({ parameters: (_, { data }) => data }), }, - }, - actions: { - assignError: assign({ error: (_, { data }) => data }), - assignStarterTemplate: assign({ starterTemplate: (_, { data }) => data }), - displayError: (_, { data }) => { - if (data instanceof Error) { - displayError(data.message) - return - } - console.warn(`data is not an Error`) + guards: { + isExampleProvided: ({ exampleId }) => Boolean(exampleId), + hasFailed: (_, { data }) => data.job.status === "failed", + hasMissingParameters: (_, { data }) => + Boolean( + data.job.error && data.job.error.includes("missing parameter"), + ), }, }, - guards: { - isExampleProvided: ({ exampleId }) => Boolean(exampleId), - }, - }, -) + ) From fe71988b18f635eb77bed4e153b18a0174e7da9b Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 14 Dec 2022 16:52:55 +0000 Subject: [PATCH 34/53] Add upload --- site/src/api/api.ts | 63 ++------ .../CreateTemplatePage/CreateTemplateForm.tsx | 12 +- .../CreateTemplatePage/CreateTemplatePage.tsx | 12 +- .../CreateTemplatePage/TemplateUpload.tsx | 136 ++++++++++++++++++ .../createTemplate/createTemplateXService.ts | 55 ++++++- 5 files changed, 220 insertions(+), 58 deletions(-) create mode 100644 site/src/pages/CreateTemplatePage/TemplateUpload.tsx diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 6cf38e3ab8438..0ca66f1481df0 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -744,60 +744,13 @@ export const getTemplateExamples = async ( return response.data } -// for creating a new template: -// 1. upload template tar or use the example ID -// 2. create template version -// 3. wait for it to complete -// 4. if the job failed with the missing parameter error then: -// a. prompt for params -// b. create template version again with the same file hash -// c. wait for it to complete -// 5.create template with the successful template version ID -// https://github.com/coder/coder/blob/b6703b11c6578b2f91a310d28b6a7e57f0069be6/cli/templatecreate.go#L169-L170 -export const createValidTemplate = async ( - organizationId: string, - exampleId: string, - // The template_version_id is calculated in the function - data: Omit, -): Promise => { - // Step 2. - let version = await createTemplateVersion(organizationId, { - storage_method: "file", - example_id: exampleId, - provisioner: "terraform", - tags: {}, - }) - - // Step 3. - let status = version.job.status - while (["pending", "running"].includes(status)) { - version = await getTemplateVersion(version.id) - status = version.job.status - } - if (status === "failed") { - console.error(version.job.error) - throw new Error(version.job.error) - } - - // // Get schema and create a new template version with the parameters - // const schema = await getTemplateVersionSchema(version.id) - // version = await createTemplateVersion(organizationId, { - // storage_method: "file", - // example_id: exampleId, - // provisioner: "terraform", - // tags: {}, - // parameter_values: schema.map((parameter) => ({ - // name: parameter.name, - // source_scheme: parameter.default_source_scheme, - // destination_scheme: parameter.default_destination_scheme, - // source_value: parameter.default_source_value, - // })), - // }) - - // Step 5. - const template = await createTemplate(organizationId, { - ...data, - template_version_id: version.id, +export const uploadTemplateFile = async ( + file: File, +): Promise => { + const response = await axios.post("/api/v2/files", file, { + headers: { + "Content-Type": "application/x-tar", + }, }) - return template + return response.data } diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index 6c04448c654d5..20c502c551f61 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -6,6 +6,10 @@ import { FormFooter } from "components/FormFooter/FormFooter" import { IconField } from "components/IconField/IconField" import { ParameterInput } from "components/ParameterInput/ParameterInput" import { Stack } from "components/Stack/Stack" +import { + TemplateUpload, + TemplateUploadProps, +} from "pages/CreateTemplatePage/TemplateUpload" import { useFormik } from "formik" import { SelectedTemplate } from "pages/CreateWorkspacePage/SelectedTemplate" import { FC } from "react" @@ -55,6 +59,7 @@ interface CreateTemplateFormProps { isSubmitting: boolean onCancel: () => void onSubmit: (data: CreateTemplateData) => void + upload: TemplateUploadProps } export const CreateTemplateForm: FC = ({ @@ -64,6 +69,7 @@ export const CreateTemplateForm: FC = ({ isSubmitting, onCancel, onSubmit, + upload, }) => { const styles = useStyles() const formFooterStyles = useFormFooterStyles() @@ -90,7 +96,11 @@ export const CreateTemplateForm: FC = ({
- {starterTemplate && } + {starterTemplate ? ( + + ) : ( + + )} { }, }, }) - const { starterTemplate, parameters, error } = state.context + const { starterTemplate, parameters, error, file } = state.context const shouldDisplayForm = !state.hasTag("loading") const onCancel = () => { @@ -58,6 +58,16 @@ const CreateTemplatePage: FC = () => { data, }) }} + upload={{ + file, + isUploading: state.matches("uploading"), + onRemove: () => { + send("REMOVE_FILE") + }, + onUpload: (file) => { + send({ type: "UPLOAD_FILE", file }) + }, + }} /> )} diff --git a/site/src/pages/CreateTemplatePage/TemplateUpload.tsx b/site/src/pages/CreateTemplatePage/TemplateUpload.tsx new file mode 100644 index 0000000000000..602204bd7686f --- /dev/null +++ b/site/src/pages/CreateTemplatePage/TemplateUpload.tsx @@ -0,0 +1,136 @@ +import { makeStyles } from "@material-ui/core/styles" +import { Stack } from "components/Stack/Stack" +import { FC, useRef } from "react" +import UploadIcon from "@material-ui/icons/CloudUploadOutlined" +import { useClickable } from "hooks/useClickable" +import CircularProgress from "@material-ui/core/CircularProgress" +import { combineClasses } from "util/combineClasses" +import IconButton from "@material-ui/core/IconButton" +import RemoveIcon from "@material-ui/icons/DeleteOutline" +import FileIcon from "@material-ui/icons/FolderOutlined" + +export interface TemplateUploadProps { + isUploading: boolean + onUpload: (file: File) => void + onRemove: () => void + file?: File +} + +export const TemplateUpload: FC = ({ + isUploading, + onUpload, + onRemove, + file, +}) => { + const styles = useStyles() + const inputRef = useRef(null) + const clickable = useClickable(() => { + if (inputRef.current) { + inputRef.current.click() + } + }) + + if (!isUploading && file) { + return ( + + + + {file.name} + + + + + + + ) + } + + return ( + <> +
+ + {isUploading ? ( + + ) : ( + + )} + + + Upload template + + The template needs to be in a .tar file + + + +
+ + { + const file = event.currentTarget.files?.[0] + if (file) { + onUpload(file) + } + }} + /> + + ) +} + +const useStyles = makeStyles((theme) => ({ + root: { + display: "flex", + alignItems: "center", + justifyContent: "center", + borderRadius: theme.shape.borderRadius, + border: `2px dashed ${theme.palette.divider}`, + padding: theme.spacing(6), + cursor: "pointer", + + "&:hover": { + backgroundColor: theme.palette.background.paper, + }, + }, + + disabled: { + pointerEvents: "none", + opacity: 0.75, + }, + + icon: { + fontSize: theme.spacing(8), + }, + + title: { + fontSize: theme.spacing(2), + }, + + description: { + color: theme.palette.text.secondary, + }, + + input: { + display: "none", + }, + + file: { + borderRadius: theme.shape.borderRadius, + border: `1px solid ${theme.palette.divider}`, + padding: theme.spacing(2), + background: theme.palette.background.paper, + }, +})) diff --git a/site/src/xServices/createTemplate/createTemplateXService.ts b/site/src/xServices/createTemplate/createTemplateXService.ts index bba4275b079de..384c76b0a940f 100644 --- a/site/src/xServices/createTemplate/createTemplateXService.ts +++ b/site/src/xServices/createTemplate/createTemplateXService.ts @@ -4,6 +4,7 @@ import { getTemplateVersion, createTemplate, getTemplateVersionSchema, + uploadTemplateFile, } from "api/api" import { CreateTemplateRequest, @@ -11,10 +12,22 @@ import { Template, TemplateExample, TemplateVersion, + UploadResponse, } from "api/typesGenerated" import { displayError } from "components/GlobalSnackbar/utils" import { assign, createMachine } from "xstate" +// for creating a new template: +// 1. upload template tar or use the example ID +// 2. create template version +// 3. wait for it to complete +// 4. if the job failed with the missing parameter error then: +// a. prompt for params +// b. create template version again with the same file hash +// c. wait for it to complete +// 5.create template with the successful template version ID +// https://github.com/coder/coder/blob/b6703b11c6578b2f91a310d28b6a7e57f0069be6/cli/templatecreate.go#L169-L170 + export interface CreateTemplateData { name: string display_name: string @@ -33,6 +46,10 @@ interface CreateTemplateContext { version?: TemplateVersion templateData?: CreateTemplateData parameters?: ParameterSchema[] + // file is used in the FE to show the filename and some other visual stuff + // uploadedFile is the response from the server to use in the API + file?: File + uploadResponse?: UploadResponse } export const createTemplateMachine = @@ -43,8 +60,14 @@ export const createTemplateMachine = predictableActionArguments: true, schema: { context: {} as CreateTemplateContext, - events: {} as { type: "CREATE"; data: CreateTemplateData }, + events: {} as + | { type: "CREATE"; data: CreateTemplateData } + | { type: "UPLOAD_FILE"; file: File } + | { type: "REMOVE_FILE" }, services: {} as { + uploadFile: { + data: UploadResponse + } loadStarterTemplate: { data: TemplateExample } @@ -92,6 +115,26 @@ export const createTemplateMachine = target: "creating", actions: ["assignTemplateData"], }, + UPLOAD_FILE: { + actions: ["assignFile"], + target: "uploading", + }, + REMOVE_FILE: { + actions: ["removeFile"], + }, + }, + }, + uploading: { + invoke: { + src: "uploadFile", + onDone: { + target: "idle", + actions: ["assignUploadResponse"], + }, + onError: { + target: "idle", + actions: ["displayUploadError", "removeFile"], + }, }, }, creating: { @@ -179,6 +222,7 @@ export const createTemplateMachine = }, { services: { + uploadFile: (_, { file }) => uploadTemplateFile(file), loadStarterTemplate: async ({ organizationId, exampleId }) => { if (!exampleId) { throw new Error(`Example ID is not defined.`) @@ -270,12 +314,21 @@ export const createTemplateMachine = displayJobError: (_, { data }) => { displayError("Provisioner job failed.", data.job.error) }, + displayUploadError: () => { + displayError("Error on upload the file.") + }, assignStarterTemplate: assign({ starterTemplate: (_, { data }) => data, }), assignVersion: assign({ version: (_, { data }) => data }), assignTemplateData: assign({ templateData: (_, { data }) => data }), assignParameters: assign({ parameters: (_, { data }) => data }), + assignFile: assign({ file: (_, { file }) => file }), + assignUploadResponse: assign({ uploadResponse: (_, { data }) => data }), + removeFile: assign({ + file: (_) => undefined, + uploadResponse: (_) => undefined, + }), }, guards: { isExampleProvided: ({ exampleId }) => Boolean(exampleId), From a9f3e188e84bc89d7922e8f4a597ff3fe9e2aaac Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 14 Dec 2022 20:46:28 +0000 Subject: [PATCH 35/53] Fix steps --- .../createTemplate/createTemplateXService.ts | 109 +++++++++++++----- 1 file changed, 80 insertions(+), 29 deletions(-) diff --git a/site/src/xServices/createTemplate/createTemplateXService.ts b/site/src/xServices/createTemplate/createTemplateXService.ts index 384c76b0a940f..417c1b690c1ad 100644 --- a/site/src/xServices/createTemplate/createTemplateXService.ts +++ b/site/src/xServices/createTemplate/createTemplateXService.ts @@ -7,7 +7,7 @@ import { uploadTemplateFile, } from "api/api" import { - CreateTemplateRequest, + CreateTemplateVersionRequest, ParameterSchema, Template, TemplateExample, @@ -53,7 +53,7 @@ interface CreateTemplateContext { } export const createTemplateMachine = - /** @xstate-layout N4IgpgJg5mDOIC5QGMBOYCGAXMAVMAtgA4A22YAdLFhqlgJYB2UAxANoAMAuoqEQPax6Dfo14gAHogCMAVgDMFDgA5ZATnkKALADY9AdgA0IAJ6I1AJmkVlyi-tk79F2bI4c1AX0-G0mHPjEZDhUNHRMrGzSPEggAkIiYrFSCHKKKuqa8roGxmYI+u4UWgoq0lpqOvJO0t6+6OSBpOQUJPwYEBEAymE4qE3BYCwQopRMAG78ANaUfo2EzSFtHd29YP0LgwgT-MjY9KKcXEfi8cIHSaApOhaK+vrZ8mocOnLK+mp5iMrSyhRqpWUHC0Wh++mUdRAcwCmxay06zB6tD6A3ILHWqH4qAoiwAZliCBRoXhYUt2gioEi6OtUThtoxJntEkcTrEzolxNdbhR7o9nq9ZO9PqZzNILMUXtI1NKAfYtPpIcTaWMICQhgBhABKAFEAIK4bWsviCc6iTmIWQWL4IWT3YoKMWuCzuLQWCE+KENGFBFrQiJEr0RABi9FQ1AAaushKJhqMKDsZgH-CSfSE-cwk-tmCGw1hI2GLvTGftDtwjXETRzkog3X8bm5neplBo3EYRalnBR5PoKs8OBYdGoHLJFV6U4tZoGM+moDmI1GLujUJjsXiCZnvRON-6Z3O8wvREXdiXGCzuKdKxdzalbTp-g7dBxpA9KsL8q6tP99DpZFp5FYOEcCxR2TZVtwzAB3DBzmzLEACl+AAI1wfgACEwHVfggjAHAIFjRgxgZaZJ1A0kSKzKAKCgmDZ3gpCUPQzDsNwo8mQuM8YmNBIr2rAp5GsNQSjleQRMAnRlGtOQOH+GUHAsUEQWkV4QPmVNyIYSDoI02jUAQ5C0IwrDSBwyB8MIyZEyVMjwMo6jtKDOj9MYoy1RYnY2NLY5ogvbizV4h4BKEnsRPkMSJPbexrB7aVlEeQppHkd16lItSbKorTg0chjDOY0yMSxHFgnxVBCSs1KZ3SmiHN0+iDKY4y3KIjzTzLc82UvPyrhkW9734x9nyeQdrRE6xLREywNGbHQOAVD0yq3CqiExYgsAABVoDACBMsMWHVAB5AA5XAAEkDoAVUNNquNNS5JBkKw1AoX4e37Ac3VuSTO3lH9bVcD4LFuJLPRShap0omdlTM+MiMsscwIqiGyNYk8OJ8m7rzkb9evKaaBtfa1lDvWx+wSwmny0GbZuS1TQf8Hcwch-LVyK9d5sGNLEbU5HmVaziK1826Ukxu8AT63GXyG9tfmkkKQop5xu1cbwPUYfgIDgcQ2fINGqy6hAAFo30QfXuRlM3zbUIGtZCahkQiHWeL111rTUBKeSUlRvw0AE9BUzd2fhVZkRpMiHc6u6EGqfQno+Qdm2+qoQQJyoKCqexZGkFQnmBP3x3Z+hVTAMPBZkYKKHsbJpsE1xnyNm07VF6RM8xj4c7muHrJnYvryea0qmKc3ynUTOn1z+GwZsvd82jW72UdiP3kk1QnqfVxAPBH43CtjvyonuzMpqpycoayBu94kFHoUzRf3439LB0T7pNUOQ3EqWOXh0MfO4npajLWjatp9HgO1AWGN+x-A3hoMUdhYryU+uKIcP1hz-UBl-XedNpwM1DiA9GvEEqvC7JoWwLhoH6ClJJHQn5XCWx7C4P8P55BoNpuQCAZ89bPkzjYGBVgLBDlihweQkkZqpxeLyX8Vhmy1GVkAA */ + /** @xstate-layout N4IgpgJg5mDOIC5QGMBOYCGAXMAVMAtgA4A22YAdLFhqlgJYB2UAxANoAMAuoqEQPax6Dfo14gAHogCMAVgDMFDgA5ZATlkAWaR1kA2TcukAaEAE9E8jgCYKa62r0B2Wao7zlatQF9vptJg4+MRkOFQ0dEysbNI8SCACQiJi8VIIcooq6lo6+oYm5ohGFJpeXk7W1h5O8nK+-ujkwaTkFCT8GBBRAMoROKjNoWAsEKKUTABu-ADWlAFNhC1h7Z09fWADi0MIk-zI2PSinFzH4onChymgaXpVFE41mq6aHJpO5aYW6V52ZWo10mssmkymUTnqIHmQS2rRWXWYvVo-UG5BYG1Q-FQFCWADNMQQKFC8DDlh14VBEXQNiicDtGFN9sljqd4udkuIbncHvInsoXm8PoUEA5FE9arprAZHHplBCiTTxhASMMAMIAJQAogBBXAall8QQXUQcxCyayfGRmtQlDg6TR6eRS+SVOWNaEhVr0JXDACqAAUADIAeS1ABEAPoAMQAkgG9dwzob2alENZtPcefI1PInG8OI5zUKPHo7Bxbgp3qCVKpXYFiR6wl7lSxNQBZIMANQ1Udj8biBqSlxNCHkOc0SmzaiMwOkTnzFvSmh5FCs7w4lb0OgetYWDcoAFdSGSoiMxhRdrNCW760sD0fVsw6QyDkduPqEkmhynhenHlmx3OBYLoYijyOoHjSGoKgvPmO7ureFCHnCJ7opi2KhHiqAEvKJJ3shj67IylzMgmrKfsa35AtY0glAoVSAtIjGzgushOCWRhpp4yh6NBoJwTeQxXoEURCQczCRvQqDUB2GxCKIp6MOM9IzHM14KqJDDMBpUQSVJWAyVJlxPnsL6MCR-YfoOFHXIg7yKA61jKB4CjSKOzoLnaJbUU81gqOuHg5vx6lQiJIXiZJ0myZcaKoBiWK4viGkCa0YVQNp4V6QZcmMMZRGvicpEDkaVySKmZo0WK9HUUxTgeTytilKUjG3I5VFBbh6VpQA7hgFziZiABS-AAEa4PwABCYAqvwIRgDgEAKUpUyXjhe6dRQPV9VAkaDSNY2TdNs3zblpnmYmVklWk0gvA1bGuNYgGuTmHm6CWThGCCDjQfmsp+JCakdalG29Zp227aNE1TTNpBzZAi3nspK0A2tQObaDO2oENEMHdDyrHYRp1vrE53FcOQK1LRzq1NVjG1UKa4UM4IIKDyLxyLI7Uo26Ilozp4P7VDR1w6MikI8tql1sF3Nabz-WY3tkOHTD+PKXlZlvtYFlsl+NmLjYJR3coD35k9dNfI4oGOeupSyOoLWcwhqMg3z8vY4Lytw6h8UYYlq2O9L3XO3LWMC0reOQCdTJvoVlmk5R5WU1VTHMUKoLjoCpSbg4BgOpoDuCUD+FQK29CwEIzB+rQGAELDUnwxeEu7v7wlaUXJdl1EleoNXtewJHxHR1r5GXTIm4lhwE8SvYVggvIdWuIzuaaNRViaE14J-X7BcB20x7MO35dQF3Pf9LAMVxeh2CYdhyPN2JaVt6Xh-HzXp-9-l77a9ZpXCgnlXU8nM2MglyyDsIYW0GgXAPXkPnFKO8iAYmIFgF+vcWDqm1LqT+w8yZGxXFOJcLxQR6FkNBBc2YnBKEYoQkC2gniwLCEDVKWVLgAHVhAAAsUGn3rojRu8Ft4tzSkwqKog2FYE4VXV+sl37qwKkPC6w47KM2dE5VQtQ3KFnNmae4m5tCOS0NmVw9C+GhQDsw0RHCuGyXPmhBKWEkpS0EZ1cxjAxESO7lIqSMizpkQUfHdM-8GI1QXHof4FBqJgjFGoOQzgN4NEloDHeqUFQ8PFg4xJTjkm4W8YPEmyZdYr3HhPaQPFHKGD5EAhATlQFgTkHIB6kowQc03rfAR99OopK9pfLA190lc0yQHBUOS5F5J1j-KiFU6IAOCUKf4FDWLKDLPYQw0TXK+D+owfgEA4DiC3uQUZ380gAFo1ALiOaAv4ly-gGGafEpuglqBIiiAckeCBl4LgcOONiVhbTODBCU66xjd4PgpOsTYe4XnDkdBQ2c-x8wTx5PmN4HyHiL0cC4Kw913hAqbGASFlEvDjloQ9KC3FPD2g8goUBbEdCOnzPoMoQKkJ7ygPi3W7yhQChXGBbMD05Dri8DAlpCT+n3zZT-LMC5cwUNUBkVw7hZxgSBYwgOulIqGW-l-V5tsODhOIQYJF3FIKaJkLPcJmY1CGGBHyewyqd6yzBq7UOuNYYQHFVdLIFASnaEYloMsQIXoOEZuuRZVqpxODqMK+5cCnGPw7hXSRvd3UyG4uOfBS5JTaA8K8Oq6YqhLgqDYR09ohV3P4TG9pCDobIMTafZNVTKmuRcEoWJVLNysVtnagZgiXFuKsVJet4ClBzlqFBEhHg3iaDIY6OwYIpyOHArcOJ-0RV31Bh03C9aQT2AoKo5wy9OIRrIZuXdrwc66Dck5Lt7SiRut8XHApiL7gKoeLcRZjEFyqHHu9fMAUYK1HWd4IAA */ createMachine( { id: "createTemplate", @@ -74,6 +74,9 @@ export const createTemplateMachine = createFirstVersion: { data: TemplateVersion } + createVersionWithParameters: { + data: TemplateVersion + } waitForJobToBeCompleted: { data: TemplateVersion } @@ -118,9 +121,11 @@ export const createTemplateMachine = UPLOAD_FILE: { actions: ["assignFile"], target: "uploading", + cond: "isFromScratch", }, REMOVE_FILE: { actions: ["removeFile"], + cond: "hasFile", }, }, }, @@ -194,11 +199,25 @@ export const createTemplateMachine = promptParameters: { on: { CREATE: { - target: "creatingTemplate", + target: "creatingVersionWithParameters", actions: ["assignTemplateData"], }, }, }, + creatingVersionWithParameters: { + invoke: { + src: "createVersionWithParameters", + onDone: { + target: "waitingForJobToBeCompleted", + actions: ["assignVersion"], + }, + onError: { + actions: ["assignError"], + target: "promptParameters", + }, + }, + tags: ["submitting"], + }, creatingTemplate: { invoke: { src: "createTemplate", @@ -236,15 +255,65 @@ export const createTemplateMachine = } return starterTemplate }, - createFirstVersion: async ({ organizationId, exampleId }) => { - if (!exampleId) { - throw new Error("No example ID provided") + createFirstVersion: async ({ + organizationId, + exampleId, + uploadResponse, + }) => { + if (exampleId) { + return createTemplateVersion(organizationId, { + storage_method: "file", + example_id: exampleId, + provisioner: "terraform", + tags: {}, + }) + } + + if (uploadResponse) { + return createTemplateVersion(organizationId, { + storage_method: "file", + file_id: uploadResponse.hash, + provisioner: "terraform", + tags: {}, + }) + } + + throw new Error("No file or example provided") + }, + createVersionWithParameters: async ({ + organizationId, + parameters, + templateData, + version, + }) => { + if (!version) { + throw new Error("No previous version found") + } + if (!templateData) { + throw new Error("No template data defined") + } + + const { parameter_values_by_name } = templateData + // Get parameter values if they are needed/present + const parameterValues: CreateTemplateVersionRequest["parameter_values"] = + [] + if (parameters) { + parameters.forEach((schema) => { + const value = parameter_values_by_name?.[schema.name] + parameterValues.push({ + name: schema.name, + source_value: value ?? schema.default_source_value, + destination_scheme: schema.default_destination_scheme, + source_scheme: schema.default_source_scheme, + }) + }) } return createTemplateVersion(organizationId, { storage_method: "file", - example_id: exampleId, + file_id: version.job.file_id, provisioner: "terraform", + parameter_values: parameterValues, tags: {}, }) }, @@ -267,12 +336,7 @@ export const createTemplateMachine = return getTemplateVersionSchema(version.id) }, - createTemplate: async ({ - organizationId, - version, - templateData, - parameters, - }) => { + createTemplate: async ({ organizationId, version, templateData }) => { if (!version) { throw new Error("Version not defined") } @@ -287,25 +351,10 @@ export const createTemplateMachine = ...safeTemplateData } = templateData - // Get parameter values if they are needed/present - const parameterValues: CreateTemplateRequest["parameter_values"] = [] - if (parameters) { - parameters.forEach((schema) => { - const value = parameter_values_by_name?.[schema.name] - parameterValues.push({ - name: schema.name, - source_value: value ?? schema.default_source_value, - destination_scheme: schema.default_destination_scheme, - source_scheme: schema.default_source_scheme, - }) - }) - } - return createTemplate(organizationId, { ...safeTemplateData, default_ttl_ms: templateData.default_ttl_hours * 60 * 60 * 1000, // Convert hours to ms template_version_id: version.id, - parameter_values: parameterValues, }) }, }, @@ -331,7 +380,9 @@ export const createTemplateMachine = }), }, guards: { - isExampleProvided: ({ exampleId }) => Boolean(exampleId), + isExampleProvided: ({ exampleId }) => exampleId !== undefined, + isFromScratch: ({ exampleId }) => exampleId === undefined, + hasFile: ({ file }) => file !== undefined, hasFailed: (_, { data }) => data.job.status === "failed", hasMissingParameters: (_, { data }) => Boolean( From 9ae5ed1b24aba5d7a5d8d8046f186d5543c0c4b5 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 14 Dec 2022 20:58:54 +0000 Subject: [PATCH 36/53] Update layout --- site/src/xServices/createTemplate/createTemplateXService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/xServices/createTemplate/createTemplateXService.ts b/site/src/xServices/createTemplate/createTemplateXService.ts index 417c1b690c1ad..42e59b158ce31 100644 --- a/site/src/xServices/createTemplate/createTemplateXService.ts +++ b/site/src/xServices/createTemplate/createTemplateXService.ts @@ -53,7 +53,7 @@ interface CreateTemplateContext { } export const createTemplateMachine = - /** @xstate-layout N4IgpgJg5mDOIC5QGMBOYCGAXMAVMAtgA4A22YAdLFhqlgJYB2UAxANoAMAuoqEQPax6Dfo14gAHogCMAVgDMFDgA5ZATlkAWaR1kA2TcukAaEAE9E8jgCYKa62r0B2Wao7zlatQF9vptJg4+MRkOFQ0dEysbNI8SCACQiJi8VIIcooq6lo6+oYm5ohGFJpeXk7W1h5O8nK+-ujkwaTkFCT8GBBRAMoROKjNoWAsEKKUTABu-ADWlAFNhC1h7Z09fWADi0MIk-zI2PSinFzH4onChymgaXpVFE41mq6aHJpO5aYW6V52ZWo10mssmkymUTnqIHmQS2rRWXWYvVo-UG5BYG1Q-FQFCWADNMQQKFC8DDlh14VBEXQNiicDtGFN9sljqd4udkuIbncHvInsoXm8PoUEA5FE9arprAZHHplBCiTTxhASMMAMIAJQAogBBXAall8QQXUQcxCyayfGRmtQlDg6TR6eRS+SVOWNaEhVr0JXDACqAAUADIAeS1ABEAPoAMQAkgG9dwzob2alENZtPcefI1PInG8OI5zUKPHo7Bxbgp3qCVKpXYFiR6wl7lSxNQBZIMANQ1Udj8biBqSlxNCHkOc0SmzaiMwOkTnzFvSmh5FCs7w4lb0OgetYWDcoAFdSGSoiMxhRdrNCW760sD0fVsw6QyDkduPqEkmhynhenHlmx3OBYLoYijyOoHjSGoKgvPmO7ureFCHnCJ7opi2KhHiqAEvKJJ3shj67IylzMgmrKfsa35AtY0glAoVSAtIjGzgushOCWRhpp4yh6NBoJwTeQxXoEURCQczCRvQqDUB2GxCKIp6MOM9IzHM14KqJDDMBpUQSVJWAyVJlxPnsL6MCR-YfoOFHXIg7yKA61jKB4CjSKOzoLnaJbUU81gqOuHg5vx6lQiJIXiZJ0myZcaKoBiWK4viGkCa0YVQNp4V6QZcmMMZRGvicpEDkaVySKmZo0WK9HUUxTgeTytilKUjG3I5VFBbh6VpQA7hgFziZiABS-AAEa4PwABCYAqvwIRgDgEAKUpUyXjhe6dRQPV9VAkaDSNY2TdNs3zblpnmYmVklWk0gvA1bGuNYgGuTmHm6CWThGCCDjQfmsp+JCakdalG29Zp227aNE1TTNpBzZAi3nspK0A2tQObaDO2oENEMHdDyrHYRp1vrE53FcOQK1LRzq1NVjG1UKa4UM4IIKDyLxyLI7Uo26Ilozp4P7VDR1w6MikI8tql1sF3Nabz-WY3tkOHTD+PKXlZlvtYFlsl+NmLjYJR3coD35k9dNfI4oGOeupSyOoLWcwhqMg3z8vY4Lytw6h8UYYlq2O9L3XO3LWMC0reOQCdTJvoVlmk5R5WU1VTHMUKoLjoCpSbg4BgOpoDuCUD+FQK29CwEIzB+rQGAELDUnwxeEu7v7wlaUXJdl1EleoNXtewJHxHR1r5GXTIm4lhwE8SvYVggvIdWuIzuaaNRViaE14J-X7BcB20x7MO35dQF3Pf9LAMVxeh2CYdhyPN2JaVt6Xh-HzXp-9-l77a9ZpXCgnlXU8nM2MglyyDsIYW0GgXAPXkPnFKO8iAYmIFgF+vcWDqm1LqT+w8yZGxXFOJcLxQR6FkNBBc2YnBKEYoQkC2gniwLCEDVKWVLgAHVhAAAsUGn3rojRu8Ft4tzSkwqKog2FYE4VXV+sl37qwKkPC6w47KM2dE5VQtQ3KFnNmae4m5tCOS0NmVw9C+GhQDsw0RHCuGyXPmhBKWEkpS0EZ1cxjAxESO7lIqSMizpkQUfHdM-8GI1QXHof4FBqJgjFGoOQzgN4NEloDHeqUFQ8PFg4xJTjkm4W8YPEmyZdYr3HhPaQPFHKGD5EAhATlQFgTkHIB6kowQc03rfAR99OopK9pfLA190lc0yQHBUOS5F5J1j-KiFU6IAOCUKf4FDWLKDLPYQw0TXK+D+owfgEA4DiC3uQUZ380gAFo1ALiOaAv4ly-gGGafEpuglqBIiiAckeCBl4LgcOONiVhbTODBCU66xjd4PgpOsTYe4XnDkdBQ2c-x8wTx5PmN4HyHiL0cC4Kw913hAqbGASFlEvDjloQ9KC3FPD2g8goUBbEdCOnzPoMoQKkJ7ygPi3W7yhQChXGBbMD05Dri8DAlpCT+n3zZT-LMC5cwUNUBkVw7hZxgSBYwgOulIqGW-l-V5tsODhOIQYJF3FIKaJkLPcJmY1CGGBHyewyqd6yzBq7UOuNYYQHFVdLIFASnaEYloMsQIXoOEZuuRZVqpxODqMK+5cCnGPw7hXSRvd3UyG4uOfBS5JTaA8K8Oq6YqhLgqDYR09ohV3P4TG9pCDobIMTafZNVTKmuRcEoWJVLNysVtnagZgiXFuKsVJet4ClBzlqFBEhHg3iaDIY6OwYIpyOHArcOJ-0RV31Bh03C9aQT2AoKo5wy9OIRrIZuXdrwc66Dck5Lt7SiRut8XHApiL7gKoeLcRZjEFyqHHu9fMAUYK1HWd4IAA */ + /** @xstate-layout N4IgpgJg5mDOIC5QGMBOYCGAXMAVMAtgA4A22YAdLFhqlgJYB2UAxANoAMAuoqEQPax6Dfo14gAHogCMAVgDMFDgA5ZATgBM86dO3z5agDQgAnoi0B2CmosA2WbNsGOF5WpsBfD8bSYc+YjIcKho6JlY2aR4kEAEhETEYqQQ5RRV1LR09A2MzBGVpClkOEo5pABZlZVsNNWllLx90cgDScgoSfgwIcIBlUJxUVqCwFghRSiYAN34Aa0pfFsI24M7uvoGwIeWRhGn+ZGx6UU4uU-E44WPE0GSaxQsLeXLZaTVntQVc8xKlUo59LZbHUXhZGiBFv4du01j1mP1aINhuQWFtUPxUBQVgAzDEECiQvDQ1ZdOFQBF0LbInB7RgzQ4JU7nGKXBLiO5aCiPZ6vd7lT7yb4IWqKF7aN5ucrSYEcWTgwnUyYQEijADCACUAKIAQVwmuZfEEV1E7MQsg0QulHHKFBqxXcNhqWnk8uaUMC7XoytGAFUAAoAGQA8tqACIAfQAYgBJAP67gXI1spLmV5c57yCxyWzWmwOIXyarWDg1R4lDTKDQaaSuvxEj3BL0qlhagCyQYAapqo7H49FDfFrqaEPIOBobQK9C4LLVZELXjanAZ3Pz3LUwd4IW76ytKABXUik8JjCYUfbzAnbxUUA+w8K0+lHE7cA2xJNDlPCtNPcqZ7O5ix81MRBKkUeR1ClcoSlHKVbFrJYG33Q91mYVFUHRTEcTxS862vW8j2YB8DifRgmQTFl3xNT8q3kDQKG0DQs1eJxZHKec7AoAoNAUWVR1qODNwVYkFjdcIcKOZhI3oVBqA7LYhFEE9GEmOk5hE3DhPEhhmC08IpJkrA5Jk64iIZa4yP7N9Byo24QLkIo7Q4NRlABJ55CcS1rVFMdpVAysnI3JoNMQ3SdMhPTpNk+TrjQjCsSCXFUHxISQvCsLRMkyLDOi0RTJIizE2sm5JHMbi6IYpjpXAtjgIQYErGrZQLBsconm45QXUEq9NLSqAKAAdwwK5JIxAApfgACNcH4AAhMBVX4QIwBwCAlJUmYLxS3dQr6wbhqgSMxsm6a5oWpaVryxkX3IgdjWK5JpDHG0oJqNQnPKCtWMtCorA+t4HEqDqXE6oKEO23qBqG7SDqOqbZvmxbSGWyA1rPVTNu61KMt2qG9Nhk6EfOyBLvMl8okKu7hyrc16OkRi5Cqr7auajhbSzDqK0zJiBNB91wexyH9sO1Bxrh07EZVFbUfPdSwZGHbBeh4XRYJs6kYu-YzOfM4NEs1kP1slInooF7anez6aryao6LUWwmuUSpzXNOR4L5+WIb2pX8fhtXJZRtEMXi7BEuSzH+b8MTPbxkXjp9iXkYgEntdffWbJK4Vx3KunKpYy3ECqG06f5fl3P5QKt2C8OJL6u9mFbehYCEZg-VoDACGRmTpfR2W3faCHa6gevG-CFvUDbjvYCT0jrr1yj7pkQCrE0Zz1A66pbe+2xClsLM6hLB1bblLrK-dgWB6HpuoFH8fBlgWLA6wpKtJ3U+I508+G8v6-29vqeCoooqVMtBZ3psxaqnksxKAYixSsjEeYVzln3AWRB0TECwN-CeLANQ6j1CnOew56hOQoJnKCspXAKE0JaCsyg2ZvFok4R6VRXYvyQW-PqvUjIKUYAAdWEAACwwbfLuG0e4sOCBDDhOUeH8MEfJP+M8KbJkNlKWQDluJORcpmQEgpapZFsJxBwFZdD2HKLYD6zDrwSOxpw64vCsACNbj-eS99MIJWwltV+1cdo2NEHYhxY8nEyXkWcG6VlKafhUWo+0mi3IeV0b+RQbgHYWBcNbAo5cPGsK8b1RUwi1LP0sQLHJwlgl4MAZ+aQi9rC1FUM5QswJbBCiag1eozUpQzgsB9DQFiepFOxrkgOrjg7uLDp46GO1FSlNCaneeGdaK01AYzPOCAbBWFkK4H6spijViPpuRg-AIBwHEJknAiiDbpwALRGFqhc1RB97n3JBgg3uwRqCInCGctOyQPpNP0dKIEKTCw6H5DvHpIUB4UiRMJT5sz3JWEqbbR4NQgStSeEKasdEZz2CcBWFw7gmpgu2k2MAMKqauFZs8VwTUVAFDUC8IUW99EqCzO5YoZQS6EvlvhFCUBSURItLVKCVgSiuFgjvT4spOVZOhnyw2ORmZPGsC8KC7k3gzlkA0Y+iDxF9LYfpKKxk04zIIesig-1RxuBsBoMoQJPKMVtBWO2GrrWfHKOUKVOq2GK2jirOORMICyvTik4V7xOkHzFAKvIOhrXEMqKYt6FQmqsQ9T3MSH9h7N0cRPQND1Kj6JBPUYEtFKzOW+i8TiK8NBOCxSylNCsUGI3QVm2+OafglmsBUKtegTHKEtAxM1jtzSZmXgSrVLzU3pTYT46R9jZEyVbfkCwlooJqC5GyqojhxzZzrVYthioF0MxtHYDqjxXgA10L8wobqFAMwBtUTVvMxETvYduANADwmG2te2kEXbjGsV7bVNwS8xTPCMTYMcXgvBAA */ createMachine( { id: "createTemplate", From cc3a2cb463b298e20c7f9b785e79cf71fd048467 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 14 Dec 2022 21:02:20 +0000 Subject: [PATCH 37/53] Update verbiage --- site/src/xServices/createTemplate/createTemplateXService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/xServices/createTemplate/createTemplateXService.ts b/site/src/xServices/createTemplate/createTemplateXService.ts index 42e59b158ce31..9aae2d2854236 100644 --- a/site/src/xServices/createTemplate/createTemplateXService.ts +++ b/site/src/xServices/createTemplate/createTemplateXService.ts @@ -121,7 +121,7 @@ export const createTemplateMachine = UPLOAD_FILE: { actions: ["assignFile"], target: "uploading", - cond: "isFromScratch", + cond: "isNotUsingExample", }, REMOVE_FILE: { actions: ["removeFile"], @@ -381,7 +381,7 @@ export const createTemplateMachine = }, guards: { isExampleProvided: ({ exampleId }) => exampleId !== undefined, - isFromScratch: ({ exampleId }) => exampleId === undefined, + isNotUsingExample: ({ exampleId }) => exampleId === undefined, hasFile: ({ file }) => file !== undefined, hasFailed: (_, { data }) => data.job.status === "failed", hasMissingParameters: (_, { data }) => From 2d8430bc38a6018f07e07c9b5417815d96fab3c2 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Fri, 16 Dec 2022 16:53:04 +0000 Subject: [PATCH 38/53] Use data --- site/src/xServices/createTemplate/createTemplateXService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/xServices/createTemplate/createTemplateXService.ts b/site/src/xServices/createTemplate/createTemplateXService.ts index 9aae2d2854236..4e13f4400e138 100644 --- a/site/src/xServices/createTemplate/createTemplateXService.ts +++ b/site/src/xServices/createTemplate/createTemplateXService.ts @@ -304,7 +304,7 @@ export const createTemplateMachine = name: schema.name, source_value: value ?? schema.default_source_value, destination_scheme: schema.default_destination_scheme, - source_scheme: schema.default_source_scheme, + source_scheme: "data", }) }) } From a2e07fb0456994d04af59e088a38b4ed04551c53 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Fri, 16 Dec 2022 18:13:41 +0000 Subject: [PATCH 39/53] Show logs on error --- site/src/api/api.ts | 9 ++++ site/src/components/Logs/Logs.tsx | 36 +++++++++---- .../WorkspaceBuildLogs/WorkspaceBuildLogs.tsx | 12 ++--- .../CreateTemplatePage/CreateTemplateForm.tsx | 54 ++++++++++++++++++- .../CreateTemplatePage/CreateTemplatePage.tsx | 5 +- .../createTemplate/createTemplateXService.ts | 35 ++++++++++-- 6 files changed, 125 insertions(+), 26 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 0ca66f1481df0..0d6bd4917eaca 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -754,3 +754,12 @@ export const uploadTemplateFile = async ( }) return response.data } + +export const getTemplateVersionLogs = async ( + versionId: string, +): Promise => { + const response = await axios.get( + `/api/v2/templateversions/${versionId}/logs`, + ) + return response.data +} diff --git a/site/src/components/Logs/Logs.tsx b/site/src/components/Logs/Logs.tsx index 78b667654b51b..6b70dd552c849 100644 --- a/site/src/components/Logs/Logs.tsx +++ b/site/src/components/Logs/Logs.tsx @@ -1,4 +1,5 @@ import { makeStyles } from "@material-ui/core/styles" +import { LogLevel } from "api/typesGenerated" import dayjs from "dayjs" import { FC } from "react" import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" @@ -7,6 +8,7 @@ import { combineClasses } from "../../util/combineClasses" interface Line { time: string output: string + level: LogLevel } export interface LogsProps { @@ -22,15 +24,17 @@ export const Logs: FC> = ({ return (
- {lines.map((line, idx) => ( -
- - {dayjs(line.time).format(`HH:mm:ss.SSS`)} - -      - {line.output} -
- ))} +
+ {lines.map((line, idx) => ( +
+ + {dayjs(line.time).format(`HH:mm:ss.SSS`)} + +      + {line.output} +
+ ))} +
) } @@ -43,13 +47,25 @@ const useStyles = makeStyles((theme) => ({ fontFamily: MONOSPACE_FONT_FAMILY, fontSize: 13, wordBreak: "break-all", - padding: theme.spacing(2), + padding: theme.spacing(2, 0), borderRadius: theme.shape.borderRadius, overflowX: "auto", }, + scrollWrapper: { + width: "fit-content", + }, line: { // Whitespace is significant in terminal output for alignment whiteSpace: "pre", + padding: theme.spacing(0, 3), + + "&.error": { + backgroundColor: theme.palette.error.dark, + }, + + "&.warning": { + backgroundColor: theme.palette.warning.dark, + }, }, space: { userSelect: "none", diff --git a/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx b/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx index edf9aaeed5626..ea857defc75ec 100644 --- a/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx +++ b/site/src/components/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx @@ -54,6 +54,7 @@ export const WorkspaceBuildLogs: FC = ({ logs }) => { const lines = logs.map((log) => ({ time: log.created_at, output: log.output, + level: log.log_level, })) const duration = getStageDurationInSeconds(logs) const shouldDisplayDuration = duration !== undefined @@ -68,7 +69,7 @@ export const WorkspaceBuildLogs: FC = ({ logs }) => { )} - {!isEmpty && } + {!isEmpty && } ) })} @@ -86,8 +87,8 @@ const useStyles = makeStyles((theme) => ({ header: { fontSize: 14, padding: theme.spacing(2), - paddingLeft: theme.spacing(4), - paddingRight: theme.spacing(4), + paddingLeft: theme.spacing(3), + paddingRight: theme.spacing(3), borderBottom: `1px solid ${theme.palette.divider}`, backgroundColor: theme.palette.background.paper, display: "flex", @@ -112,9 +113,4 @@ const useStyles = makeStyles((theme) => ({ color: theme.palette.text.secondary, fontSize: theme.typography.body2.fontSize, }, - - codeBlock: { - padding: theme.spacing(2), - paddingLeft: theme.spacing(4), - }, })) diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index 20c502c551f61..6e1f622ee1359 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -1,7 +1,11 @@ import Checkbox from "@material-ui/core/Checkbox" import { makeStyles } from "@material-ui/core/styles" import TextField from "@material-ui/core/TextField" -import { ParameterSchema, TemplateExample } from "api/typesGenerated" +import { + ParameterSchema, + ProvisionerJobLog, + TemplateExample, +} from "api/typesGenerated" import { FormFooter } from "components/FormFooter/FormFooter" import { IconField } from "components/IconField/IconField" import { ParameterInput } from "components/ParameterInput/ParameterInput" @@ -17,6 +21,7 @@ import { useTranslation } from "react-i18next" import { nameValidator, getFormHelpers, onChangeTrimmed } from "util/formUtils" import { CreateTemplateData } from "xServices/createTemplate/createTemplateXService" import * as Yup from "yup" +import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs" const validationSchema = Yup.object({ name: nameValidator("Name"), @@ -60,6 +65,8 @@ interface CreateTemplateFormProps { onCancel: () => void onSubmit: (data: CreateTemplateData) => void upload: TemplateUploadProps + jobError?: string + logs?: ProvisionerJobLog[] } export const CreateTemplateForm: FC = ({ @@ -70,6 +77,8 @@ export const CreateTemplateForm: FC = ({ onCancel, onSubmit, upload, + jobError, + logs, }) => { const styles = useStyles() const formFooterStyles = useFormFooterStyles() @@ -249,11 +258,27 @@ export const CreateTemplateForm: FC = ({ )} + {jobError && ( + +
+
Error during provisioning
+

+ Looks like we found an error during the template provisioning. + You can see the logs bellow. +

+ + {jobError} +
+ + +
+ )} +
@@ -318,6 +343,31 @@ const useStyles = makeStyles((theme) => ({ fontSize: theme.spacing(1.5), color: theme.palette.text.secondary, }, + + error: { + 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: { + margin: 0, + color: theme.palette.text.secondary, + marginTop: theme.spacing(0.5), + }, + + errorDetails: { + display: "block", + marginTop: theme.spacing(1), + color: theme.palette.error.light, + fontSize: theme.spacing(2), + }, })) const useFormFooterStyles = makeStyles((theme) => ({ diff --git a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx index 0e4516246e0c6..863e153d1bec2 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx @@ -27,7 +27,8 @@ const CreateTemplatePage: FC = () => { }, }, }) - const { starterTemplate, parameters, error, file } = state.context + const { starterTemplate, parameters, error, file, jobError, jobLogs } = + state.context const shouldDisplayForm = !state.hasTag("loading") const onCancel = () => { @@ -68,6 +69,8 @@ const CreateTemplatePage: FC = () => { send({ type: "UPLOAD_FILE", file }) }, }} + jobError={jobError} + logs={jobLogs} /> )} diff --git a/site/src/xServices/createTemplate/createTemplateXService.ts b/site/src/xServices/createTemplate/createTemplateXService.ts index 4e13f4400e138..73ff939853c19 100644 --- a/site/src/xServices/createTemplate/createTemplateXService.ts +++ b/site/src/xServices/createTemplate/createTemplateXService.ts @@ -5,10 +5,12 @@ import { createTemplate, getTemplateVersionSchema, uploadTemplateFile, + getTemplateVersionLogs, } from "api/api" import { CreateTemplateVersionRequest, ParameterSchema, + ProvisionerJobLog, Template, TemplateExample, TemplateVersion, @@ -41,6 +43,7 @@ interface CreateTemplateContext { organizationId: string error?: unknown jobError?: string + jobLogs?: ProvisionerJobLog[] starterTemplate?: TemplateExample exampleId?: string | null // It can be null because it is being passed from query string version?: TemplateVersion @@ -86,6 +89,9 @@ export const createTemplateMachine = createTemplate: { data: Template } + loadVersionLogs: { + data: ProvisionerJobLog[] + } }, }, tsTypes: {} as import("./createTemplateXService.typegen").Typegen0, @@ -169,8 +175,8 @@ export const createTemplateMachine = actions: ["assignVersion"], }, { - target: "#createTemplate.idle", - actions: ["displayJobError"], + target: "loadingVersionLogs", + actions: ["assignJobError", "assignVersion"], cond: "hasFailed", }, { target: "creatingTemplate", actions: ["assignVersion"] }, @@ -182,6 +188,19 @@ export const createTemplateMachine = }, tags: ["submitting"], }, + loadingVersionLogs: { + invoke: { + src: "loadVersionLogs", + onDone: { + target: "#createTemplate.idle", + actions: ["assignJobLogs"], + }, + onError: { + target: "#createTemplate.idle", + actions: ["assignError"], + }, + }, + }, loadingMissingParameters: { invoke: { src: "loadParameterSchema", @@ -357,12 +376,17 @@ export const createTemplateMachine = template_version_id: version.id, }) }, + loadVersionLogs: ({ version }) => { + if (!version) { + throw new Error("Version is not set") + } + + return getTemplateVersionLogs(version.id) + }, }, actions: { assignError: assign({ error: (_, { data }) => data }), - displayJobError: (_, { data }) => { - displayError("Provisioner job failed.", data.job.error) - }, + assignJobError: assign({ jobError: (_, { data }) => data.job.error }), displayUploadError: () => { displayError("Error on upload the file.") }, @@ -378,6 +402,7 @@ export const createTemplateMachine = file: (_) => undefined, uploadResponse: (_) => undefined, }), + assignJobLogs: assign({ jobLogs: (_, { data }) => data }), }, guards: { isExampleProvided: ({ exampleId }) => exampleId !== undefined, From 43c8faa9071be98f9ebbfb8ab8bbfc2da52ed7a1 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 20 Dec 2022 13:36:35 +0000 Subject: [PATCH 40/53] Add templates link --- site/src/hooks/useEntitlements.ts | 14 +++++++++++ .../pages/TemplatesPage/TemplatesPageView.tsx | 23 +++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 site/src/hooks/useEntitlements.ts diff --git a/site/src/hooks/useEntitlements.ts b/site/src/hooks/useEntitlements.ts new file mode 100644 index 0000000000000..96780e2a0a050 --- /dev/null +++ b/site/src/hooks/useEntitlements.ts @@ -0,0 +1,14 @@ +import { useSelector } from "@xstate/react" +import { Entitlements } from "api/typesGenerated" +import { useContext } from "react" +import { XServiceContext } from "xServices/StateContext" + +export const useEntitlements = (): Entitlements => { + const xServices = useContext(XServiceContext) + const entitlements = useSelector( + xServices.entitlementsXService, + (state) => state.context.entitlements, + ) + + return entitlements +} diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index 64acc2f5f9fb0..c903c101bdf62 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -1,3 +1,4 @@ +import Button from "@material-ui/core/Button" import Link from "@material-ui/core/Link" import { makeStyles, Theme } from "@material-ui/core/styles" import Table from "@material-ui/core/Table" @@ -7,14 +8,16 @@ import TableContainer from "@material-ui/core/TableContainer" import TableHead from "@material-ui/core/TableHead" import TableRow from "@material-ui/core/TableRow" import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight" +import AddIcon from "@material-ui/icons/AddOutlined" import useTheme from "@material-ui/styles/useTheme" import { AlertBanner } from "components/AlertBanner/AlertBanner" import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" import { Maybe } from "components/Conditionals/Maybe" import { TableEmpty } from "components/TableEmpty/TableEmpty" +import { useEntitlements } from "hooks/useEntitlements" import { FC } from "react" import { useTranslation } from "react-i18next" -import { useNavigate } from "react-router-dom" +import { useNavigate, Link as RouterLink } from "react-router-dom" import { createDayString } from "util/createDayString" import { formatTemplateBuildTime, @@ -39,6 +42,7 @@ import { HelpTooltipText, HelpTooltipTitle, } from "../../components/Tooltips/HelpTooltip/HelpTooltip" +import { usePermissions } from "hooks/usePermissions" export const Language = { developerCount: (activeCount: number): string => { @@ -106,10 +110,25 @@ export const TemplatesPageView: FC< !props.getOrganizationsError && !props.getTemplatesError && !props.templates?.length + const entitlements = useEntitlements() + const permissions = usePermissions() return ( - + + + + + } + > Templates From e64db78eb833a339946c14298b2a32683a6a00d3 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 20 Dec 2022 14:27:15 +0000 Subject: [PATCH 41/53] Fix upload --- .../CreateTemplatePage/TemplateUpload.tsx | 30 ++++++++++++++++++- .../createTemplate/createTemplateXService.ts | 6 ++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/site/src/pages/CreateTemplatePage/TemplateUpload.tsx b/site/src/pages/CreateTemplatePage/TemplateUpload.tsx index 602204bd7686f..8fac90e734e4c 100644 --- a/site/src/pages/CreateTemplatePage/TemplateUpload.tsx +++ b/site/src/pages/CreateTemplatePage/TemplateUpload.tsx @@ -1,6 +1,6 @@ import { makeStyles } from "@material-ui/core/styles" import { Stack } from "components/Stack/Stack" -import { FC, useRef } from "react" +import { FC, DragEvent, useRef } from "react" import UploadIcon from "@material-ui/icons/CloudUploadOutlined" import { useClickable } from "hooks/useClickable" import CircularProgress from "@material-ui/core/CircularProgress" @@ -9,6 +9,32 @@ import IconButton from "@material-ui/core/IconButton" import RemoveIcon from "@material-ui/icons/DeleteOutline" import FileIcon from "@material-ui/icons/FolderOutlined" +const useTarDrop = ( + callback: (file: File) => void, +): { + onDragOver: (e: DragEvent) => void + onDrop: (e: DragEvent) => void +} => { + const onDragOver = (e: DragEvent) => { + e.preventDefault() + } + + const onDrop = (e: DragEvent) => { + e.preventDefault() + const file = e.dataTransfer.files[0] + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- file can be undefined + if (!file || file.type !== "application/x-tar") { + return + } + callback(file) + } + + return { + onDragOver, + onDrop, + } +} + export interface TemplateUploadProps { isUploading: boolean onUpload: (file: File) => void @@ -24,6 +50,7 @@ export const TemplateUpload: FC = ({ }) => { const styles = useStyles() const inputRef = useRef(null) + const tarDrop = useTarDrop(onUpload) const clickable = useClickable(() => { if (inputRef.current) { inputRef.current.click() @@ -58,6 +85,7 @@ export const TemplateUpload: FC = ({ [styles.disabled]: isUploading, })} {...clickable} + {...tarDrop} > {isUploading ? ( diff --git a/site/src/xServices/createTemplate/createTemplateXService.ts b/site/src/xServices/createTemplate/createTemplateXService.ts index 73ff939853c19..73490ec17598d 100644 --- a/site/src/xServices/createTemplate/createTemplateXService.ts +++ b/site/src/xServices/createTemplate/createTemplateXService.ts @@ -405,9 +405,9 @@ export const createTemplateMachine = assignJobLogs: assign({ jobLogs: (_, { data }) => data }), }, guards: { - isExampleProvided: ({ exampleId }) => exampleId !== undefined, - isNotUsingExample: ({ exampleId }) => exampleId === undefined, - hasFile: ({ file }) => file !== undefined, + isExampleProvided: ({ exampleId }) => Boolean(exampleId), + isNotUsingExample: ({ exampleId }) => !exampleId, + hasFile: ({ file }) => Boolean(file), hasFailed: (_, { data }) => data.job.status === "failed", hasMissingParameters: (_, { data }) => Boolean( From cec4b00fe167379721c7f084d71efd9f103acc4e Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 20 Dec 2022 14:55:26 +0000 Subject: [PATCH 42/53] Add link to starter templates --- site/src/i18n/en/createTemplatePage.json | 4 +++ .../CreateTemplatePage/TemplateUpload.tsx | 27 ++++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/site/src/i18n/en/createTemplatePage.json b/site/src/i18n/en/createTemplatePage.json index 1da7058e15de5..3d8c424be3402 100644 --- a/site/src/i18n/en/createTemplatePage.json +++ b/site/src/i18n/en/createTemplatePage.json @@ -32,6 +32,10 @@ "helperText": { "autoStop": "Time in hours", "allowUsersToCancel": "Not recommended" + }, + "upload": { + "removeTitle": "Remove file", + "title": "Upload template" } } } diff --git a/site/src/pages/CreateTemplatePage/TemplateUpload.tsx b/site/src/pages/CreateTemplatePage/TemplateUpload.tsx index 8fac90e734e4c..6aa121db68481 100644 --- a/site/src/pages/CreateTemplatePage/TemplateUpload.tsx +++ b/site/src/pages/CreateTemplatePage/TemplateUpload.tsx @@ -8,6 +8,9 @@ import { combineClasses } from "util/combineClasses" import IconButton from "@material-ui/core/IconButton" import RemoveIcon from "@material-ui/icons/DeleteOutline" import FileIcon from "@material-ui/icons/FolderOutlined" +import { useTranslation } from "react-i18next" +import Link from "@material-ui/core/Link" +import { Link as RouterLink } from "react-router-dom" const useTarDrop = ( callback: (file: File) => void, @@ -56,6 +59,7 @@ export const TemplateUpload: FC = ({ inputRef.current.click() } }) + const { t } = useTranslation("createTemplatePage") if (!isUploading && file) { return ( @@ -70,7 +74,11 @@ export const TemplateUpload: FC = ({ {file.name} - + @@ -95,9 +103,20 @@ export const TemplateUpload: FC = ({ )} - Upload template + {t("form.upload.title")} - The template needs to be in a .tar file + The template has to be a .tar file. You can also use our{" "} + { + e.stopPropagation() + }} + > + starter templates + {" "} + to getting started with Coder. @@ -149,6 +168,8 @@ const useStyles = makeStyles((theme) => ({ description: { color: theme.palette.text.secondary, + textAlign: "center", + maxWidth: theme.spacing(50), }, input: { From 8b0f95ba12a22e22b0b21e9cb484318061e1c734 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 20 Dec 2022 17:50:15 +0000 Subject: [PATCH 43/53] Add empty state --- .../TemplateExampleCard.tsx | 91 ++++++++++++ site/src/i18n/en/templatesPage.json | 4 + .../StarterTemplatesPageView.tsx | 66 +-------- .../pages/TemplatesPage/EmptyTemplates.tsx | 133 ++++++++++++++++++ .../src/pages/TemplatesPage/TemplatesPage.tsx | 27 ++-- .../pages/TemplatesPage/TemplatesPageView.tsx | 100 +++---------- .../xServices/templates/templatesXService.ts | 95 +++++-------- 7 files changed, 302 insertions(+), 214 deletions(-) create mode 100644 site/src/components/TemplateExampleCard/TemplateExampleCard.tsx create mode 100644 site/src/pages/TemplatesPage/EmptyTemplates.tsx diff --git a/site/src/components/TemplateExampleCard/TemplateExampleCard.tsx b/site/src/components/TemplateExampleCard/TemplateExampleCard.tsx new file mode 100644 index 0000000000000..042e9904a323b --- /dev/null +++ b/site/src/components/TemplateExampleCard/TemplateExampleCard.tsx @@ -0,0 +1,91 @@ +import { makeStyles } from "@material-ui/core/styles" +import { TemplateExample } from "api/typesGenerated" +import { FC } from "react" +import { Link } from "react-router-dom" +import { combineClasses } from "util/combineClasses" + +export interface TemplateExampleCardProps { + example: TemplateExample + className?: string +} + +export const TemplateExampleCard: FC = ({ + example, + className, +}) => { + const styles = useStyles() + + return ( + +
+ +
+
+ {example.name} + + {example.description} + +
+ + ) +} + +const useStyles = makeStyles((theme) => ({ + template: { + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + background: theme.palette.background.paper, + textDecoration: "none", + textAlign: "left", + color: "inherit", + display: "flex", + alignItems: "center", + height: "fit-content", + + "&:hover": { + backgroundColor: theme.palette.background.paperLight, + }, + }, + + templateIcon: { + width: theme.spacing(12), + height: theme.spacing(12), + display: "flex", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + + "& img": { + height: theme.spacing(4), + }, + }, + + templateInfo: { + padding: theme.spacing(2, 2, 2, 0), + display: "flex", + flexDirection: "column", + gap: theme.spacing(0.5), + overflow: "hidden", + }, + + templateName: { + fontSize: theme.spacing(2), + textOverflow: "ellipsis", + width: "100%", + overflow: "hidden", + whiteSpace: "nowrap", + }, + + templateDescription: { + fontSize: theme.spacing(1.75), + color: theme.palette.text.secondary, + textOverflow: "ellipsis", + width: "100%", + overflow: "hidden", + whiteSpace: "nowrap", + }, +})) diff --git a/site/src/i18n/en/templatesPage.json b/site/src/i18n/en/templatesPage.json index 34d56b2788f07..44e14723ec0e4 100644 --- a/site/src/i18n/en/templatesPage.json +++ b/site/src/i18n/en/templatesPage.json @@ -2,5 +2,9 @@ "errors": { "getOrganizationError": "Something went wrong fetching organizations.", "getTemplatesError": "Something went wrong fetching templates." + }, + "empty": { + "message": "Create your first template", + "descriptionWithoutPermissions": "Contact your Coder administrator to create a template. You can share the code below." } } diff --git a/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx index 86fe20ed23291..40d4a58448877 100644 --- a/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx +++ b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx @@ -9,6 +9,7 @@ import { PageHeaderTitle, } from "components/PageHeader/PageHeader" import { Stack } from "components/Stack/Stack" +import { TemplateExampleCard } from "components/TemplateExampleCard/TemplateExampleCard" import { FC } from "react" import { useTranslation } from "react-i18next" import { Link, useSearchParams } from "react-router-dom" @@ -85,21 +86,7 @@ export const StarterTemplatesPageView: FC = ({
{visibleTemplates && visibleTemplates.map((example) => ( - -
- -
-
- {example.name} - - {example.description} - -
- + ))}
@@ -144,53 +131,4 @@ const useStyles = makeStyles((theme) => ({ gap: theme.spacing(2), gridAutoRows: "min-content", }, - - template: { - border: `1px solid ${theme.palette.divider}`, - borderRadius: theme.shape.borderRadius, - background: theme.palette.background.paper, - textDecoration: "none", - color: "inherit", - display: "flex", - alignItems: "center", - height: "fit-content", - - "&:hover": { - backgroundColor: theme.palette.background.paperLight, - }, - }, - - templateIcon: { - width: theme.spacing(12), - height: theme.spacing(12), - display: "flex", - alignItems: "center", - justifyContent: "center", - flexShrink: 0, - - "& img": { - height: theme.spacing(4), - }, - }, - - templateInfo: { - padding: theme.spacing(2, 2, 2, 0), - display: "flex", - flexDirection: "column", - gap: theme.spacing(0.5), - overflow: "hidden", - }, - - templateName: { - fontSize: theme.spacing(2), - }, - - templateDescription: { - fontSize: theme.spacing(1.75), - color: theme.palette.text.secondary, - textOverflow: "ellipsis", - width: "100%", - overflow: "hidden", - whiteSpace: "nowrap", - }, })) diff --git a/site/src/pages/TemplatesPage/EmptyTemplates.tsx b/site/src/pages/TemplatesPage/EmptyTemplates.tsx new file mode 100644 index 0000000000000..562b9ced05b59 --- /dev/null +++ b/site/src/pages/TemplatesPage/EmptyTemplates.tsx @@ -0,0 +1,133 @@ +import Button from "@material-ui/core/Button" +import Link from "@material-ui/core/Link" +import { makeStyles } from "@material-ui/core/styles" +import { TemplateExample } from "api/typesGenerated" +import { CodeExample } from "components/CodeExample/CodeExample" +import { Stack } from "components/Stack/Stack" +import { TableEmpty } from "components/TableEmpty/TableEmpty" +import { TemplateExampleCard } from "components/TemplateExampleCard/TemplateExampleCard" +import { FC } from "react" +import { useTranslation } from "react-i18next" +import { Link as RouterLink } from "react-router-dom" +import { Permissions } from "xServices/auth/authXService" + +// Those are from https://github.com/coder/coder/tree/main/examples/templates +const featuredExamples = [ + "docker", + "kubernetes", + "aws-linux", + "aws-windows", + "gcp-linux", + "gcp-windows", +] + +const findFeaturedExamples = (examples: TemplateExample[]) => { + return examples.filter((example) => featuredExamples.includes(example.id)) +} + +export const EmptyTemplates: FC<{ + permissions: Permissions + examples: TemplateExample[] +}> = ({ permissions, examples }) => { + const styles = useStyles() + const { t } = useTranslation("templatesPage") + const featuredExamples = findFeaturedExamples(examples) + + if (permissions.createTemplates) { + return ( + + You can create a template using our starter templates or{" "} + + uploading a template + + . You can also{" "} + + use the CLI + + . + + } + cta={ + +
+ {featuredExamples.map((example) => ( + + ))} +
+ + +
+ } + /> + ) + } + + return ( + } + image={ +
+ +
+ } + /> + ) +} + +const useStyles = makeStyles((theme) => ({ + withImage: { + paddingBottom: 0, + }, + + emptyImage: { + maxWidth: "50%", + height: theme.spacing(40), + overflow: "hidden", + opacity: 0.85, + + "& img": { + maxWidth: "100%", + }, + }, + + featuredExamples: { + maxWidth: theme.spacing(100), + display: "grid", + gridTemplateColumns: "repeat(2, minmax(0, 1fr))", + gap: theme.spacing(2), + gridAutoRows: "min-content", + }, + + template: { + backgroundColor: theme.palette.background.paperLight, + + "&:hover": { + backgroundColor: theme.palette.divider, + }, + }, + + viewAllButton: { + borderRadius: 9999, + }, +})) diff --git a/site/src/pages/TemplatesPage/TemplatesPage.tsx b/site/src/pages/TemplatesPage/TemplatesPage.tsx index 970da10d7908c..a6eff4f7496d5 100644 --- a/site/src/pages/TemplatesPage/TemplatesPage.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPage.tsx @@ -1,17 +1,21 @@ -import { useActor, useMachine } from "@xstate/react" -import React, { useContext } from "react" +import { useMachine } from "@xstate/react" +import { useOrganizationId } from "hooks/useOrganizationId" +import { usePermissions } from "hooks/usePermissions" +import React from "react" import { Helmet } from "react-helmet-async" import { pageTitle } from "../../util/page" -import { XServiceContext } from "../../xServices/StateContext" import { templatesMachine } from "../../xServices/templates/templatesXService" import { TemplatesPageView } from "./TemplatesPageView" export const TemplatesPage: React.FC = () => { - const xServices = useContext(XServiceContext) - const [authState] = useActor(xServices.authXService) - const [templatesState] = useMachine(templatesMachine) - const { templates, getOrganizationsError, getTemplatesError } = - templatesState.context + const organizationId = useOrganizationId() + const permissions = usePermissions() + const [templatesState] = useMachine(templatesMachine, { + context: { + organizationId, + permissions, + }, + }) return ( <> @@ -19,11 +23,8 @@ export const TemplatesPage: React.FC = () => { Codestin Search App ) diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index c903c101bdf62..8d7e69db7aa1e 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -13,19 +13,15 @@ import useTheme from "@material-ui/styles/useTheme" import { AlertBanner } from "components/AlertBanner/AlertBanner" import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" import { Maybe } from "components/Conditionals/Maybe" -import { TableEmpty } from "components/TableEmpty/TableEmpty" import { useEntitlements } from "hooks/useEntitlements" import { FC } from "react" -import { useTranslation } from "react-i18next" import { useNavigate, Link as RouterLink } from "react-router-dom" import { createDayString } from "util/createDayString" import { formatTemplateBuildTime, formatTemplateActiveDevelopers, } from "util/templates" -import * as TypesGen from "../../api/typesGenerated" import { AvatarData } from "../../components/AvatarData/AvatarData" -import { CodeExample } from "../../components/CodeExample/CodeExample" import { Margins } from "../../components/Margins/Margins" import { PageHeader, @@ -43,6 +39,9 @@ import { HelpTooltipTitle, } from "../../components/Tooltips/HelpTooltip/HelpTooltip" import { usePermissions } from "hooks/usePermissions" +import { EmptyTemplates } from "./EmptyTemplates" +import { TemplatesContext } from "xServices/templates/templatesXService" +import { Permissions } from "xServices/auth/authXService" export const Language = { developerCount: (activeCount: number): string => { @@ -54,21 +53,6 @@ export const Language = { buildTimeLabel: "Build time", usedByLabel: "Used by", lastUpdatedLabel: "Last updated", - emptyViewNoPerms: - "Contact your Coder administrator to create a template. You can share the code below.", - emptyMessage: "Create your first template", - emptyDescription: ( - <> - To create a workspace you need to have a template. You can{" "} - - create one from scratch - {" "} - or use a built-in template using the following Coder CLI command: - - ), templateTooltipTitle: "What is template?", templateTooltipText: "With templates you can create a common configuration for your workspaces using Terraform.", @@ -91,25 +75,19 @@ const TemplateHelpTooltip: React.FC = () => { } export interface TemplatesPageViewProps { - loading?: boolean - canCreateTemplate?: boolean - templates?: TypesGen.Template[] - getOrganizationsError?: Error | unknown - getTemplatesError?: Error | unknown + context: TemplatesContext + permissions: Permissions } export const TemplatesPageView: FC< React.PropsWithChildren -> = (props) => { +> = ({ context }) => { const styles = useStyles() const navigate = useNavigate() - const { t } = useTranslation("templatesPage") const theme: Theme = useTheme() - const empty = - !props.loading && - !props.getOrganizationsError && - !props.getTemplatesError && - !props.templates?.length + const { templates, error, examples } = context + const isLoading = !templates + const isEmpty = Boolean(templates && templates.length === 0) const entitlements = useEntitlements() const permissions = usePermissions() @@ -135,12 +113,10 @@ export const TemplatesPageView: FC<
- 0)} - > + 0)}> Choose a template to create a new workspace - {props.canCreateTemplate ? ( + {permissions.createTemplates ? ( <> , or{" "} - - - - - + + + @@ -187,30 +153,20 @@ export const TemplatesPageView: FC< - + - - } - image={ -
- -
- } + + + - {props.templates?.map((template) => { + {templates?.map((template) => { const templatePageLink = `/templates/${template.name}` const hasIcon = template.icon && template.icon !== "" @@ -338,18 +294,4 @@ const useStyles = makeStyles((theme) => ({ width: "100%", }, }, - empty: { - paddingBottom: 0, - }, - - emptyImage: { - maxWidth: "50%", - height: theme.spacing(40), - overflow: "hidden", - opacity: 0.85, - - "& img": { - maxWidth: "100%", - }, - }, })) diff --git a/site/src/xServices/templates/templatesXService.ts b/site/src/xServices/templates/templatesXService.ts index 33224b564ccb7..2486e57337050 100644 --- a/site/src/xServices/templates/templatesXService.ts +++ b/site/src/xServices/templates/templatesXService.ts @@ -1,13 +1,14 @@ +import { Permissions } from "xServices/auth/authXService" import { assign, createMachine } from "xstate" import * as API from "../../api/api" import * as TypesGen from "../../api/typesGenerated" -interface TemplatesContext { - organizations?: TypesGen.Organization[] +export interface TemplatesContext { + organizationId: string + permissions: Permissions templates?: TypesGen.Template[] - canCreateTemplate?: boolean - getOrganizationsError?: Error | unknown - getTemplatesError?: Error | unknown + examples?: TypesGen.TemplateExample[] + error?: Error | unknown } export const templatesMachine = createMachine( @@ -18,80 +19,58 @@ export const templatesMachine = createMachine( schema: { context: {} as TemplatesContext, services: {} as { - getOrganizations: { - data: TypesGen.Organization[] - } - getTemplates: { - data: TypesGen.Template[] + load: { + data: { + templates: TypesGen.Template[] + examples: TypesGen.TemplateExample[] + } } }, }, - initial: "gettingOrganizations", + initial: "loading", states: { - gettingOrganizations: { - entry: "clearGetOrganizationsError", + loading: { invoke: { - src: "getOrganizations", - id: "getOrganizations", + src: "load", + id: "load", onDone: { - actions: ["assignOrganizations"], - target: "gettingTemplates", + actions: ["assignData"], + target: "idle", }, onError: { - actions: "assignGetOrganizationsError", - target: "error", + actions: "assignError", + target: "idle", }, }, - tags: "loading", }, - gettingTemplates: { - entry: "clearGetTemplatesError", - invoke: { - src: "getTemplates", - id: "getTemplates", - onDone: { - actions: "assignTemplates", - target: "done", - }, - onError: { - actions: "assignGetTemplatesError", - target: "error", - }, - }, - tags: "loading", + idle: { + type: "final", }, - done: {}, - error: {}, }, }, { actions: { - assignOrganizations: assign({ - organizations: (_, event) => event.data, - }), - assignGetOrganizationsError: assign({ - getOrganizationsError: (_, event) => event.data, + assignData: assign({ + templates: (_, event) => event.data.templates, + examples: (_, event) => event.data.examples, }), - clearGetOrganizationsError: assign((context) => ({ - ...context, - getOrganizationsError: undefined, - })), - assignTemplates: assign({ - templates: (_, event) => event.data, + assignError: assign({ + error: (_, { data }) => data, }), - assignGetTemplatesError: assign({ - getTemplatesError: (_, event) => event.data, - }), - clearGetTemplatesError: (context) => - assign({ ...context, getTemplatesError: undefined }), }, services: { - getOrganizations: API.getOrganizations, - getTemplates: async (context) => { - if (!context.organizations || context.organizations.length === 0) { - throw new Error("no organizations") + load: async ({ organizationId, permissions }) => { + const [templates, examples] = await Promise.all([ + API.getTemplates(organizationId), + permissions.createTemplates + ? API.getTemplateExamples(organizationId) + : Promise.resolve([]), + ]) + + return { + templates, + examples, } - return API.getTemplates(context.organizations[0].id) }, }, }, From 6de5c5210624e915369ce4ca6ca432975f55877d Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 20 Dec 2022 18:13:05 +0000 Subject: [PATCH 44/53] Create empty state and experimental tags --- .../pages/TemplatesPage/EmptyTemplates.tsx | 34 ++++- .../src/pages/TemplatesPage/TemplatesPage.tsx | 4 +- .../TemplatesPageView.stories.tsx | 124 +++++++++++++----- .../pages/TemplatesPage/TemplatesPageView.tsx | 13 +- site/src/testHelpers/entities.ts | 16 ++- 5 files changed, 144 insertions(+), 47 deletions(-) diff --git a/site/src/pages/TemplatesPage/EmptyTemplates.tsx b/site/src/pages/TemplatesPage/EmptyTemplates.tsx index 562b9ced05b59..f21d5b0d377e4 100644 --- a/site/src/pages/TemplatesPage/EmptyTemplates.tsx +++ b/site/src/pages/TemplatesPage/EmptyTemplates.tsx @@ -1,7 +1,7 @@ import Button from "@material-ui/core/Button" import Link from "@material-ui/core/Link" import { makeStyles } from "@material-ui/core/styles" -import { TemplateExample } from "api/typesGenerated" +import { Entitlements, TemplateExample } from "api/typesGenerated" import { CodeExample } from "components/CodeExample/CodeExample" import { Stack } from "components/Stack/Stack" import { TableEmpty } from "components/TableEmpty/TableEmpty" @@ -28,12 +28,13 @@ const findFeaturedExamples = (examples: TemplateExample[]) => { export const EmptyTemplates: FC<{ permissions: Permissions examples: TemplateExample[] -}> = ({ permissions, examples }) => { + entitlements: Entitlements +}> = ({ permissions, examples, entitlements }) => { const styles = useStyles() const { t } = useTranslation("templatesPage") const featuredExamples = findFeaturedExamples(examples) - if (permissions.createTemplates) { + if (permissions.createTemplates && entitlements.experimental) { return ( + To create a workspace you need to have a template. You can{" "} + + create one from scratch + {" "} + or use a built-in template using the following Coder CLI command: + + } + cta={} + image={ +
+ +
+ } + /> + ) + } + return ( { const organizationId = useOrganizationId() const permissions = usePermissions() + const entitlements = useEntitlements() const [templatesState] = useMachine(templatesMachine, { context: { organizationId, @@ -24,7 +26,7 @@ export const TemplatesPage: React.FC = () => { ) diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx index c150cbb902abc..abb84f5b03f20 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx @@ -1,5 +1,13 @@ import { ComponentMeta, Story } from "@storybook/react" -import { makeMockApiError, MockTemplate } from "../../testHelpers/entities" +import { + makeMockApiError, + MockEntitlements, + MockOrganization, + MockPermissions, + MockTemplate, + MockTemplateExample, + MockTemplateExample2, +} from "../../testHelpers/entities" import { TemplatesPageView, TemplatesPageViewProps } from "./TemplatesPageView" export default { @@ -11,50 +19,100 @@ const Template: Story = (args) => ( ) -export const AllStates = Template.bind({}) -AllStates.args = { - canCreateTemplate: true, - templates: [ - MockTemplate, - { - ...MockTemplate, - active_user_count: -1, - description: "🚀 Some new template that has no activity data", - icon: "/icon/goland.svg", - }, - { - ...MockTemplate, - active_user_count: 150, - description: "😮 Wow, this one has a bunch of usage!", - icon: "", - }, - { - ...MockTemplate, - description: - "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. ", - }, - ], +export const WithTemplates = Template.bind({}) +WithTemplates.args = { + entitlements: MockEntitlements, + context: { + organizationId: MockOrganization.id, + permissions: MockPermissions, + error: undefined, + templates: [ + MockTemplate, + { + ...MockTemplate, + active_user_count: -1, + description: "🚀 Some new template that has no activity data", + icon: "/icon/goland.svg", + }, + { + ...MockTemplate, + active_user_count: 150, + description: "😮 Wow, this one has a bunch of usage!", + icon: "", + }, + { + ...MockTemplate, + description: + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. ", + }, + ], + examples: [], + }, } -export const SmallViewport = Template.bind({}) -SmallViewport.args = { - ...AllStates.args, +export const WithTemplatesSmallViewPort = Template.bind({}) +WithTemplatesSmallViewPort.args = { + ...WithTemplates.args, } -SmallViewport.parameters = { +WithTemplatesSmallViewPort.parameters = { chromatic: { viewports: [600] }, } export const EmptyCanCreate = Template.bind({}) EmptyCanCreate.args = { - canCreateTemplate: true, + entitlements: MockEntitlements, + context: { + organizationId: MockOrganization.id, + permissions: MockPermissions, + error: undefined, + templates: [], + examples: [MockTemplateExample, MockTemplateExample2], + }, +} + +export const EmptyCanCreateExperimental = Template.bind({}) +EmptyCanCreateExperimental.args = { + entitlements: { + ...MockEntitlements, + experimental: true, + }, + context: { + organizationId: MockOrganization.id, + permissions: MockPermissions, + error: undefined, + templates: [], + examples: [MockTemplateExample, MockTemplateExample2], + }, } export const EmptyCannotCreate = Template.bind({}) -EmptyCannotCreate.args = {} +EmptyCannotCreate.args = { + entitlements: MockEntitlements, + context: { + organizationId: MockOrganization.id, + permissions: { + ...MockPermissions, + createTemplates: false, + }, + error: undefined, + templates: [], + examples: [MockTemplateExample, MockTemplateExample2], + }, +} export const Error = Template.bind({}) Error.args = { - getTemplatesError: makeMockApiError({ - message: "Something went wrong fetching templates.", - }), + entitlements: MockEntitlements, + context: { + organizationId: MockOrganization.id, + permissions: { + ...MockPermissions, + createTemplates: false, + }, + error: makeMockApiError({ + message: "Something went wrong fetching templates.", + }), + templates: undefined, + examples: undefined, + }, } diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index 8d7e69db7aa1e..8f06ed341f043 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -13,7 +13,6 @@ import useTheme from "@material-ui/styles/useTheme" import { AlertBanner } from "components/AlertBanner/AlertBanner" import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" import { Maybe } from "components/Conditionals/Maybe" -import { useEntitlements } from "hooks/useEntitlements" import { FC } from "react" import { useNavigate, Link as RouterLink } from "react-router-dom" import { createDayString } from "util/createDayString" @@ -38,10 +37,9 @@ import { HelpTooltipText, HelpTooltipTitle, } from "../../components/Tooltips/HelpTooltip/HelpTooltip" -import { usePermissions } from "hooks/usePermissions" import { EmptyTemplates } from "./EmptyTemplates" import { TemplatesContext } from "xServices/templates/templatesXService" -import { Permissions } from "xServices/auth/authXService" +import { Entitlements } from "api/typesGenerated" export const Language = { developerCount: (activeCount: number): string => { @@ -76,20 +74,18 @@ const TemplateHelpTooltip: React.FC = () => { export interface TemplatesPageViewProps { context: TemplatesContext - permissions: Permissions + entitlements: Entitlements } export const TemplatesPageView: FC< React.PropsWithChildren -> = ({ context }) => { +> = ({ context, entitlements }) => { const styles = useStyles() const navigate = useNavigate() const theme: Theme = useTheme() - const { templates, error, examples } = context + const { templates, error, examples, permissions } = context const isLoading = !templates const isEmpty = Boolean(templates && templates.length === 0) - const entitlements = useEntitlements() - const permissions = usePermissions() return ( @@ -162,6 +158,7 @@ export const TemplatesPageView: FC<
diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 3b079b0705a41..026f39377b994 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -3,6 +3,7 @@ import { everyOneGroup } from "util/groups" import * as Types from "../api/types" import * as TypesGen from "../api/typesGenerated" import { range } from "lodash" +import { Permissions } from "xServices/auth/authXService" export const MockTemplateDAUResponse: TypesGen.TemplateDAUsResponse = { entries: [ @@ -1065,8 +1066,8 @@ export const MockTemplateACLEmpty: TypesGen.TemplateACL = { } export const MockTemplateExample: TypesGen.TemplateExample = { - id: "aws-ecs-container", - url: "https://github.com/coder/coder/tree/main/examples/templates/aws-ecs-container", + id: "aws-windows", + url: "https://github.com/coder/coder/tree/main/examples/templates/aws-windows", name: "Develop in an ECS-hosted container", description: "Get started with Linux development on AWS ECS.", markdown: @@ -1085,3 +1086,14 @@ export const MockTemplateExample2: TypesGen.TemplateExample = { icon: "/icon/aws.png", tags: ["aws", "cloud"], } + +export const MockPermissions: Permissions = { + createGroup: true, + createTemplates: true, + createUser: true, + deleteTemplates: true, + readAllUsers: true, + updateUsers: true, + viewAuditLog: true, + viewDeploymentConfig: true, +} From 228bc58990e458b92cf788003aff1660e2df3df7 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 20 Dec 2022 18:20:27 +0000 Subject: [PATCH 45/53] Add help tooltip --- site/src/i18n/en/createTemplatePage.json | 3 +++ .../CreateTemplatePage/CreateTemplateForm.tsx | 16 ++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/site/src/i18n/en/createTemplatePage.json b/site/src/i18n/en/createTemplatePage.json index 3d8c424be3402..6789d80ab1c45 100644 --- a/site/src/i18n/en/createTemplatePage.json +++ b/site/src/i18n/en/createTemplatePage.json @@ -36,6 +36,9 @@ "upload": { "removeTitle": "Remove file", "title": "Upload template" + }, + "tooltip": { + "allowUsersToCancel": "Depending on your template, canceling builds may leave workspaces in an unhealthy state. This option isn't recommended for most use cases." } } } diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index 6e1f622ee1359..8fb99e2540441 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -22,6 +22,7 @@ import { nameValidator, getFormHelpers, onChangeTrimmed } from "util/formUtils" import { CreateTemplateData } from "xServices/createTemplate/createTemplateXService" import * as Yup from "yup" import { WorkspaceBuildLogs } from "components/WorkspaceBuildLogs/WorkspaceBuildLogs" +import { HelpTooltip, HelpTooltipText } from "components/Tooltips/HelpTooltip" const validationSchema = Yup.object({ name: nameValidator("Name"), @@ -216,9 +217,20 @@ export const CreateTemplateForm: FC = ({ /> - + {t("form.fields.allowUsersToCancel")} - + + + + {t("form.tooltip.allowUsersToCancel")} + + + {t("form.helperText.allowUsersToCancel")} From 155e9019f0ad646a6a6dfed782452c3827328813 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 20 Dec 2022 18:35:31 +0000 Subject: [PATCH 46/53] Fix tests --- .../TemplatesPage/TemplatesPage.test.tsx | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/site/src/pages/TemplatesPage/TemplatesPage.test.tsx b/site/src/pages/TemplatesPage/TemplatesPage.test.tsx index f504c4209a1df..b424ebb55048e 100644 --- a/site/src/pages/TemplatesPage/TemplatesPage.test.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPage.test.tsx @@ -2,17 +2,18 @@ import { screen } from "@testing-library/react" import { rest } from "msw" import * as CreateDayString from "util/createDayString" import { MockTemplate } from "../../testHelpers/entities" -import { history, render } from "../../testHelpers/renderHelpers" +import { renderWithAuth } from "../../testHelpers/renderHelpers" import { server } from "../../testHelpers/server" import { TemplatesPage } from "./TemplatesPage" -import { Language } from "./TemplatesPageView" +import i18next from "i18next" + +const { t } = i18next describe("TemplatesPage", () => { beforeEach(() => { // Mocking the dayjs module within the createDayString file const mock = jest.spyOn(CreateDayString, "createDayString") mock.mockImplementation(() => "a minute ago") - history.replace("/workspaces") }) it("renders an empty templates page", async () => { @@ -35,15 +36,24 @@ describe("TemplatesPage", () => { ) // When - render() + renderWithAuth(, { + route: `/templates`, + path: "/templates", + }) // Then - await screen.findByText(Language.emptyMessage) + const emptyMessage = t("empty.message", { + ns: "templatesPage", + }) + await screen.findByText(emptyMessage) }) it("renders a filled templates page", async () => { // When - render() + renderWithAuth(, { + route: `/templates`, + path: "/templates", + }) // Then await screen.findByText(MockTemplate.display_name) @@ -68,9 +78,14 @@ describe("TemplatesPage", () => { ) // When - render() - + renderWithAuth(, { + route: `/templates`, + path: "/templates", + }) // Then - await screen.findByText(Language.emptyViewNoPerms) + const emptyMessage = t("empty.descriptionWithoutPermissions", { + ns: "templatesPage", + }) + await screen.findByText(emptyMessage) }) }) From 4edfed8510ed618b13dfd56f8f2eba870be1f566 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Tue, 20 Dec 2022 18:47:31 +0000 Subject: [PATCH 47/53] Lazy load starter template page --- site/src/AppRouter.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 5b8eb056d5a1a..ff59edb4924ef 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -22,7 +22,6 @@ import { AuthAndFrame } from "./components/AuthAndFrame/AuthAndFrame" import { RequireAuth } from "./components/RequireAuth/RequireAuth" import { SettingsLayout } from "./components/SettingsLayout/SettingsLayout" import { DeploySettingsLayout } from "components/DeploySettingsLayout/DeploySettingsLayout" -import StarterTemplatePage from "pages/StarterTemplatePage/StarterTemplatePage" // Lazy load pages // - Pages that are secondary, not in the main navigation or not usually accessed @@ -96,6 +95,9 @@ const TemplateVersionPage = lazy( const StarterTemplatesPage = lazy( () => import("./pages/StarterTemplatesPage/StarterTemplatesPage"), ) +const StarterTemplatePage = lazy( + () => import("pages/StarterTemplatePage/StarterTemplatePage"), +) const CreateTemplatePage = lazy( () => import("./pages/CreateTemplatePage/CreateTemplatePage"), ) From 30e017e5875401638170422cb0bcbbbdc2d9f7cd Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 21 Dec 2022 12:11:17 -0300 Subject: [PATCH 48/53] Apply suggestions from code review Co-authored-by: Presley Pizzo <1290996+presleyp@users.noreply.github.com> --- site/src/i18n/en/createTemplatePage.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/site/src/i18n/en/createTemplatePage.json b/site/src/i18n/en/createTemplatePage.json index 6789d80ab1c45..e31df28043151 100644 --- a/site/src/i18n/en/createTemplatePage.json +++ b/site/src/i18n/en/createTemplatePage.json @@ -11,15 +11,15 @@ }, "schedule": { "title": "Schedule", - "description": "Define when a workspace create from this template is going to stop." + "description": "Define when a workspace created from this template is going to stop." }, "operations": { "title": "Operations", - "description": "Allow or not users to run specific actions on the workspace." + "description": "Allow or disallow users to run specific actions on the workspace." }, "parameters": { "title": "Template params", - "description": "Those params are provided by your template's Terraform configuration." + "description": "These params are provided by your template's Terraform configuration." }, "fields": { "name": "Name", From 98a5f8433485f941500fe1fb91d6c72bdd236bfc Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 21 Dec 2022 15:14:48 +0000 Subject: [PATCH 49/53] No need to trim display name and description --- site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index 8fb99e2540441..0af1e1bf8a327 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -139,7 +139,6 @@ export const CreateTemplateForm: FC = ({ = ({ Date: Wed, 21 Dec 2022 15:59:46 +0000 Subject: [PATCH 50/53] Return undefined it is not n api error --- site/src/util/formUtils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/site/src/util/formUtils.ts b/site/src/util/formUtils.ts index c76010e81b394..cc25b7f02fbc1 100644 --- a/site/src/util/formUtils.ts +++ b/site/src/util/formUtils.ts @@ -47,7 +47,8 @@ export const getFormHelpers = const apiValidationErrors = isApiError(error) && hasApiFieldErrors(error) ? (mapApiErrorToFieldErrors(error.response.data) as FormikErrors) - : error + : undefined + if (typeof name !== "string") { throw new Error( `name must be type of string, instead received '${typeof name}'`, @@ -62,6 +63,7 @@ export const getFormHelpers = const apiError = getIn(apiValidationErrors, apiErrorName) const frontendError = getIn(form.errors, name) const returnError = apiError ?? frontendError + return { ...form.getFieldProps(name), id: name, From f8e8d3336dce645b65112f485efdc792fc9d0417 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 21 Dec 2022 16:03:59 +0000 Subject: [PATCH 51/53] Display error --- site/src/api/errors.ts | 4 ++ .../CreateTemplatePage/CreateTemplatePage.tsx | 63 +++++++++++-------- site/src/util/formUtils.ts | 13 ++-- 3 files changed, 44 insertions(+), 36 deletions(-) diff --git a/site/src/api/errors.ts b/site/src/api/errors.ts index 1663e0333dabb..496bf493e98ef 100644 --- a/site/src/api/errors.ts +++ b/site/src/api/errors.ts @@ -63,6 +63,10 @@ export const mapApiErrorToFieldErrors = ( return result } +export const isApiValidationError = (error: unknown): error is ApiError => { + return isApiError(error) && hasApiFieldErrors(error) +} + /** * * @param error diff --git a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx index 863e153d1bec2..66adcca70fc7b 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplatePage.tsx @@ -1,7 +1,10 @@ import { useMachine } from "@xstate/react" +import { isApiValidationError } from "api/errors" +import { AlertBanner } from "components/AlertBanner/AlertBanner" import { Maybe } from "components/Conditionals/Maybe" import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizontalForm" import { Loader } from "components/Loader/Loader" +import { Stack } from "components/Stack/Stack" import { useOrganizationId } from "hooks/useOrganizationId" import { FC } from "react" import { Helmet } from "react-helmet-async" @@ -46,33 +49,39 @@ const CreateTemplatePage: FC = () => {
- {shouldDisplayForm && ( - { - send({ - type: "CREATE", - data, - }) - }} - upload={{ - file, - isUploading: state.matches("uploading"), - onRemove: () => { - send("REMOVE_FILE") - }, - onUpload: (file) => { - send({ type: "UPLOAD_FILE", file }) - }, - }} - jobError={jobError} - logs={jobLogs} - /> - )} + + + + + + {shouldDisplayForm && ( + { + send({ + type: "CREATE", + data, + }) + }} + upload={{ + file, + isUploading: state.matches("uploading"), + onRemove: () => { + send("REMOVE_FILE") + }, + onUpload: (file) => { + send({ type: "UPLOAD_FILE", file }) + }, + }} + jobError={jobError} + logs={jobLogs} + /> + )} + ) diff --git a/site/src/util/formUtils.ts b/site/src/util/formUtils.ts index cc25b7f02fbc1..b620dbae2d00b 100644 --- a/site/src/util/formUtils.ts +++ b/site/src/util/formUtils.ts @@ -1,8 +1,4 @@ -import { - hasApiFieldErrors, - isApiError, - mapApiErrorToFieldErrors, -} from "api/errors" +import { isApiValidationError, mapApiErrorToFieldErrors } from "api/errors" import { FormikContextType, FormikErrors, getIn } from "formik" import { ChangeEvent, @@ -44,10 +40,9 @@ export const getFormHelpers = HelperText: ReactNode = "", backendErrorName?: string, ): FormHelpers => { - const apiValidationErrors = - isApiError(error) && hasApiFieldErrors(error) - ? (mapApiErrorToFieldErrors(error.response.data) as FormikErrors) - : undefined + const apiValidationErrors = isApiValidationError(error) + ? (mapApiErrorToFieldErrors(error.response.data) as FormikErrors) + : undefined if (typeof name !== "string") { throw new Error( From be4de6af3f0d97beec113d5fdad79f05325e13bb Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 21 Dec 2022 16:17:53 +0000 Subject: [PATCH 52/53] Return error --- site/src/util/formUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/util/formUtils.ts b/site/src/util/formUtils.ts index b620dbae2d00b..ebe9e1fef9a59 100644 --- a/site/src/util/formUtils.ts +++ b/site/src/util/formUtils.ts @@ -42,7 +42,7 @@ export const getFormHelpers = ): FormHelpers => { const apiValidationErrors = isApiValidationError(error) ? (mapApiErrorToFieldErrors(error.response.data) as FormikErrors) - : undefined + : error if (typeof name !== "string") { throw new Error( From e75714fd99fe8d73f825965ec2049af883f38d85 Mon Sep 17 00:00:00 2001 From: Bruno Quaresma Date: Wed, 21 Dec 2022 16:18:28 +0000 Subject: [PATCH 53/53] Fix test --- site/src/util/formUtils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/site/src/util/formUtils.ts b/site/src/util/formUtils.ts index ebe9e1fef9a59..b750375961744 100644 --- a/site/src/util/formUtils.ts +++ b/site/src/util/formUtils.ts @@ -42,7 +42,8 @@ export const getFormHelpers = ): FormHelpers => { const apiValidationErrors = isApiValidationError(error) ? (mapApiErrorToFieldErrors(error.response.data) as FormikErrors) - : error + : // This should not return the error since it is not and api validation error but I didn't have time to fix this and tests + error if (typeof name !== "string") { throw new Error(