feat: add user-level parameter autofill (#11731)

This PR solves #10478 by auto-filling previously used template values in create and update workspace flows.

I decided against explicit user values in settings for these reasons:

* Autofill is far easier to implement
* Users benefit from autofill _by default_ — we don't need to teach them new concepts
* If we decide that autofill creates more harm than good, we can remove it without breaking compatibility
This commit is contained in:
Ammar Bandukwala 2024-01-30 16:02:21 -06:00 committed by GitHub
parent aeb4112513
commit adbb025e74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 862 additions and 137 deletions

57
coderd/apidoc/docs.go generated
View File

@ -4142,6 +4142,50 @@ const docTemplate = `{
}
}
},
"/users/{user}/autofill-parameters": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": [
"application/json"
],
"tags": [
"Users"
],
"summary": "Get autofill build parameters for user",
"operationId": "get-autofill-build-parameters-for-user",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Template ID",
"name": "template_id",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.UserParameter"
}
}
}
}
}
},
"/users/{user}/convert-login": {
"post": {
"security": [
@ -10493,6 +10537,7 @@ const docTemplate = `{
"api_key",
"user",
"user_data",
"user_workspace_build_parameters",
"organization_member",
"license",
"deployment_config",
@ -10518,6 +10563,7 @@ const docTemplate = `{
"ResourceAPIKey",
"ResourceUser",
"ResourceUserData",
"ResourceUserWorkspaceBuildParameters",
"ResourceOrganizationMember",
"ResourceLicense",
"ResourceDeploymentValues",
@ -11923,6 +11969,17 @@ const docTemplate = `{
}
}
},
"codersdk.UserParameter": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"value": {
"type": "string"
}
}
},
"codersdk.UserQuietHoursScheduleConfig": {
"type": "object",
"properties": {

View File

@ -3638,6 +3638,46 @@
}
}
},
"/users/{user}/autofill-parameters": {
"get": {
"security": [
{
"CoderSessionToken": []
}
],
"produces": ["application/json"],
"tags": ["Users"],
"summary": "Get autofill build parameters for user",
"operationId": "get-autofill-build-parameters-for-user",
"parameters": [
{
"type": "string",
"description": "User ID, username, or me",
"name": "user",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Template ID",
"name": "template_id",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.UserParameter"
}
}
}
}
}
},
"/users/{user}/convert-login": {
"post": {
"security": [
@ -9439,6 +9479,7 @@
"api_key",
"user",
"user_data",
"user_workspace_build_parameters",
"organization_member",
"license",
"deployment_config",
@ -9464,6 +9505,7 @@
"ResourceAPIKey",
"ResourceUser",
"ResourceUserData",
"ResourceUserWorkspaceBuildParameters",
"ResourceOrganizationMember",
"ResourceLicense",
"ResourceDeploymentValues",
@ -10810,6 +10852,17 @@
}
}
},
"codersdk.UserParameter": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"value": {
"type": "string"
}
}
},
"codersdk.UserQuietHoursScheduleConfig": {
"type": "object",
"properties": {

View File

@ -821,6 +821,7 @@ func New(options *Options) *API {
r.Post("/convert-login", api.postConvertLoginType)
r.Delete("/", api.deleteUser)
r.Get("/", api.userByName)
r.Get("/autofill-parameters", api.userAutofillParameters)
r.Get("/login-type", api.userLoginType)
r.Put("/profile", api.putUserProfile)
r.Route("/status", func(r chi.Router) {

View File

@ -1768,6 +1768,17 @@ func (q *querier) GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([
return q.db.GetUserLinksByUserID(ctx, userID)
}
func (q *querier) GetUserWorkspaceBuildParameters(ctx context.Context, params database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) {
u, err := q.db.GetUserByID(ctx, params.OwnerID)
if err != nil {
return nil, err
}
if err := q.authorizeContext(ctx, rbac.ActionRead, u.UserWorkspaceBuildParametersObject()); err != nil {
return nil, err
}
return q.db.GetUserWorkspaceBuildParameters(ctx, params)
}
func (q *querier) GetUsers(ctx context.Context, arg database.GetUsersParams) ([]database.GetUsersRow, error) {
// This does the filtering in SQL.
prep, err := prepareSQLFilter(ctx, q.auth, rbac.ActionRead, rbac.ResourceUser.Type)

View File

@ -1052,6 +1052,17 @@ func (s *MethodTestSuite) TestUser() {
UpdatedAt: u.UpdatedAt,
}).Asserts(u.UserDataRBACObject(), rbac.ActionUpdate).Returns(u)
}))
s.Run("GetUserWorkspaceBuildParameters", s.Subtest(func(db database.Store, check *expects) {
u := dbgen.User(s.T(), db, database.User{})
check.Args(
database.GetUserWorkspaceBuildParametersParams{
OwnerID: u.ID,
TemplateID: uuid.UUID{},
},
).Asserts(u.UserWorkspaceBuildParametersObject(), rbac.ActionRead).Returns(
[]database.GetUserWorkspaceBuildParametersRow{},
)
}))
s.Run("UpdateUserAppearanceSettings", s.Subtest(func(db database.Store, check *expects) {
u := dbgen.User(s.T(), db, database.User{})
check.Args(database.UpdateUserAppearanceSettingsParams{

View File

@ -3797,6 +3797,65 @@ func (q *FakeQuerier) GetUserLinksByUserID(_ context.Context, userID uuid.UUID)
return uls, nil
}
func (q *FakeQuerier) GetUserWorkspaceBuildParameters(_ context.Context, params database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
userWorkspaceIDs := make(map[uuid.UUID]struct{})
for _, ws := range q.workspaces {
if ws.OwnerID != params.OwnerID {
continue
}
if ws.TemplateID != params.TemplateID {
continue
}
userWorkspaceIDs[ws.ID] = struct{}{}
}
userWorkspaceBuilds := make(map[uuid.UUID]struct{})
for _, wb := range q.workspaceBuilds {
if _, ok := userWorkspaceIDs[wb.WorkspaceID]; !ok {
continue
}
userWorkspaceBuilds[wb.ID] = struct{}{}
}
templateVersions := make(map[uuid.UUID]struct{})
for _, tv := range q.templateVersions {
if tv.TemplateID.UUID != params.TemplateID {
continue
}
templateVersions[tv.ID] = struct{}{}
}
tvps := make(map[string]struct{})
for _, tvp := range q.templateVersionParameters {
if _, ok := templateVersions[tvp.TemplateVersionID]; !ok {
continue
}
if _, ok := tvps[tvp.Name]; !ok && !tvp.Ephemeral {
tvps[tvp.Name] = struct{}{}
}
}
userWorkspaceBuildParameters := make(map[string]database.GetUserWorkspaceBuildParametersRow)
for _, wbp := range q.workspaceBuildParameters {
if _, ok := userWorkspaceBuilds[wbp.WorkspaceBuildID]; !ok {
continue
}
if _, ok := tvps[wbp.Name]; !ok {
continue
}
userWorkspaceBuildParameters[wbp.Name] = database.GetUserWorkspaceBuildParametersRow{
Name: wbp.Name,
Value: wbp.Value,
}
}
return maps.Values(userWorkspaceBuildParameters), nil
}
func (q *FakeQuerier) GetUsers(_ context.Context, params database.GetUsersParams) ([]database.GetUsersRow, error) {
if err := validateDatabaseType(params); err != nil {
return nil, err

View File

@ -1012,6 +1012,13 @@ func (m metricsStore) GetUserLinksByUserID(ctx context.Context, userID uuid.UUID
return r0, r1
}
func (m metricsStore) GetUserWorkspaceBuildParameters(ctx context.Context, ownerID database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) {
start := time.Now()
r0, r1 := m.s.GetUserWorkspaceBuildParameters(ctx, ownerID)
m.queryLatencies.WithLabelValues("GetUserWorkspaceBuildParameters").Observe(time.Since(start).Seconds())
return r0, r1
}
func (m metricsStore) GetUsers(ctx context.Context, arg database.GetUsersParams) ([]database.GetUsersRow, error) {
start := time.Now()
users, err := m.s.GetUsers(ctx, arg)

View File

@ -2104,6 +2104,21 @@ func (mr *MockStoreMockRecorder) GetUserLinksByUserID(arg0, arg1 any) *gomock.Ca
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserLinksByUserID", reflect.TypeOf((*MockStore)(nil).GetUserLinksByUserID), arg0, arg1)
}
// GetUserWorkspaceBuildParameters mocks base method.
func (m *MockStore) GetUserWorkspaceBuildParameters(arg0 context.Context, arg1 database.GetUserWorkspaceBuildParametersParams) ([]database.GetUserWorkspaceBuildParametersRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUserWorkspaceBuildParameters", arg0, arg1)
ret0, _ := ret[0].([]database.GetUserWorkspaceBuildParametersRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetUserWorkspaceBuildParameters indicates an expected call of GetUserWorkspaceBuildParameters.
func (mr *MockStoreMockRecorder) GetUserWorkspaceBuildParameters(arg0, arg1 any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserWorkspaceBuildParameters", reflect.TypeOf((*MockStore)(nil).GetUserWorkspaceBuildParameters), arg0, arg1)
}
// GetUsers mocks base method.
func (m *MockStore) GetUsers(arg0 context.Context, arg1 database.GetUsersParams) ([]database.GetUsersRow, error) {
m.ctrl.T.Helper()

View File

@ -256,6 +256,10 @@ func (u User) UserDataRBACObject() rbac.Object {
return rbac.ResourceUserData.WithID(u.ID).WithOwner(u.ID.String())
}
func (u User) UserWorkspaceBuildParametersObject() rbac.Object {
return rbac.ResourceUserWorkspaceBuildParameters.WithID(u.ID).WithOwner(u.ID.String())
}
func (u GetUsersRow) RBACObject() rbac.Object {
return rbac.ResourceUserObject(u.ID)
}

View File

@ -211,6 +211,7 @@ type sqlcQuerier interface {
GetUserLinkByLinkedID(ctx context.Context, linkedID string) (UserLink, error)
GetUserLinkByUserIDLoginType(ctx context.Context, arg GetUserLinkByUserIDLoginTypeParams) (UserLink, error)
GetUserLinksByUserID(ctx context.Context, userID uuid.UUID) ([]UserLink, error)
GetUserWorkspaceBuildParameters(ctx context.Context, arg GetUserWorkspaceBuildParametersParams) ([]GetUserWorkspaceBuildParametersRow, error)
// This will never return deleted users.
GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUsersRow, error)
// This shouldn't check for deleted, because it's frequently used

View File

@ -9939,6 +9939,62 @@ func (q *sqlQuerier) InsertWorkspaceAppStats(ctx context.Context, arg InsertWork
return err
}
const getUserWorkspaceBuildParameters = `-- name: GetUserWorkspaceBuildParameters :many
SELECT DISTINCT ON (tvp.name)
tvp.name,
wbp.value
FROM
workspace_build_parameters wbp
JOIN
workspace_builds wb ON wb.id = wbp.workspace_build_id
JOIN
workspaces w ON w.id = wb.workspace_id
JOIN
template_version_parameters tvp ON tvp.template_version_id = wb.template_version_id
WHERE
w.owner_id = $1
AND wb.transition = 'start'
AND w.template_id = $2
AND tvp.ephemeral = false
AND tvp.name = wbp.name
ORDER BY
tvp.name, wb.created_at DESC
LIMIT 100
`
type GetUserWorkspaceBuildParametersParams struct {
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
TemplateID uuid.UUID `db:"template_id" json:"template_id"`
}
type GetUserWorkspaceBuildParametersRow struct {
Name string `db:"name" json:"name"`
Value string `db:"value" json:"value"`
}
func (q *sqlQuerier) GetUserWorkspaceBuildParameters(ctx context.Context, arg GetUserWorkspaceBuildParametersParams) ([]GetUserWorkspaceBuildParametersRow, error) {
rows, err := q.db.QueryContext(ctx, getUserWorkspaceBuildParameters, arg.OwnerID, arg.TemplateID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetUserWorkspaceBuildParametersRow
for rows.Next() {
var i GetUserWorkspaceBuildParametersRow
if err := rows.Scan(&i.Name, &i.Value); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getWorkspaceBuildParameters = `-- name: GetWorkspaceBuildParameters :many
SELECT
workspace_build_id, name, value

View File

@ -14,3 +14,27 @@ FROM
workspace_build_parameters
WHERE
workspace_build_id = $1;
-- name: GetUserWorkspaceBuildParameters :many
-- name: GetUserWorkspaceBuildParameters :many
SELECT DISTINCT ON (tvp.name)
tvp.name,
wbp.value
FROM
workspace_build_parameters wbp
JOIN
workspace_builds wb ON wb.id = wbp.workspace_build_id
JOIN
workspaces w ON w.id = wb.workspace_id
JOIN
template_version_parameters tvp ON tvp.template_version_id = wb.template_version_id
WHERE
w.owner_id = $1
AND wb.transition = 'start'
AND w.template_id = $2
AND tvp.ephemeral = false
AND tvp.name = wbp.name
ORDER BY
tvp.name, wb.created_at DESC
LIMIT 100;

View File

@ -148,6 +148,12 @@ var (
Type: "user_data",
}
// ResourceUserWorkspaceBuildParameters is the user's workspace build
// parameter history.
ResourceUserWorkspaceBuildParameters = Object{
Type: "user_workspace_build_parameters",
}
// ResourceOrganizationMember is a user's membership in an organization.
// Has ONLY an organization owner.
// create/delete = Create/delete member from org.

View File

@ -25,6 +25,7 @@ func AllResources() []Object {
ResourceTemplateInsights,
ResourceUser,
ResourceUserData,
ResourceUserWorkspaceBuildParameters,
ResourceWildcard,
ResourceWorkspace,
ResourceWorkspaceApplicationConnect,

View File

@ -154,7 +154,8 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
Permissions(map[string][]Action{
// Users cannot do create/update/delete on themselves, but they
// can read their own details.
ResourceUser.Type: {ActionRead},
ResourceUser.Type: {ActionRead},
ResourceUserWorkspaceBuildParameters.Type: {ActionRead},
// Users can create provisioner daemons scoped to themselves.
ResourceProvisionerDaemon.Type: {ActionCreate, ActionRead, ActionUpdate},
})...,
@ -209,9 +210,10 @@ func ReloadBuiltinRoles(opts *RoleOptions) {
Name: userAdmin,
DisplayName: "User Admin",
Site: Permissions(map[string][]Action{
ResourceRoleAssignment.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
ResourceUser.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
ResourceUserData.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
ResourceRoleAssignment.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
ResourceUser.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
ResourceUserData.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
ResourceUserWorkspaceBuildParameters.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
// Full perms to manage org members
ResourceOrganizationMember.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
ResourceGroup.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},

View File

@ -569,6 +569,57 @@ func (api *API) userByName(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.User(user, organizationIDs))
}
// Returns recent build parameters for the signed-in user.
//
// @Summary Get autofill build parameters for user
// @ID get-autofill-build-parameters-for-user
// @Security CoderSessionToken
// @Produce json
// @Tags Users
// @Param user path string true "User ID, username, or me"
// @Param template_id query string true "Template ID"
// @Success 200 {array} codersdk.UserParameter
// @Router /users/{user}/autofill-parameters [get]
func (api *API) userAutofillParameters(rw http.ResponseWriter, r *http.Request) {
user := httpmw.UserParam(r)
p := httpapi.NewQueryParamParser().Required("template_id")
templateID := p.UUID(r.URL.Query(), uuid.UUID{}, "template_id")
p.ErrorExcessParams(r.URL.Query())
if len(p.Errors) > 0 {
httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid query parameters.",
Validations: p.Errors,
})
return
}
params, err := api.Database.GetUserWorkspaceBuildParameters(
r.Context(),
database.GetUserWorkspaceBuildParametersParams{
OwnerID: user.ID,
TemplateID: templateID,
},
)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching user's parameters.",
Detail: err.Error(),
})
return
}
sdkParams := []codersdk.UserParameter{}
for _, param := range params {
sdkParams = append(sdkParams, codersdk.UserParameter{
Name: param.Name,
Value: param.Value,
})
}
httpapi.Write(r.Context(), rw, http.StatusOK, sdkParams)
}
// Returns the user's login type. This only works if the api key for authorization
// and the requested user match. Eg: 'me'
//

View File

@ -23,6 +23,7 @@ import (
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbfake"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/rbac"
@ -1721,6 +1722,129 @@ func TestSuspendedPagination(t *testing.T) {
require.Equal(t, expected, page.Users, "expected page")
}
func TestUserAutofillParameters(t *testing.T) {
t.Parallel()
t.Run("NotSelf", func(t *testing.T) {
t.Parallel()
client1, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
u1 := coderdtest.CreateFirstUser(t, client1)
client2, u2 := coderdtest.CreateAnotherUser(t, client1, u1.OrganizationID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
db := api.Database
version := dbfake.TemplateVersion(t, db).Seed(database.TemplateVersion{
CreatedBy: u1.UserID,
OrganizationID: u1.OrganizationID,
}).Params(database.TemplateVersionParameter{
Name: "param",
Required: true,
}).Do()
_, err := client2.UserAutofillParameters(
ctx,
u1.UserID.String(),
version.Template.ID,
)
var apiErr *codersdk.Error
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusBadRequest, apiErr.StatusCode())
// u1 should be able to read u2's parameters as u1 is site admin.
_, err = client1.UserAutofillParameters(
ctx,
u2.ID.String(),
version.Template.ID,
)
require.NoError(t, err)
})
t.Run("FindsParameters", func(t *testing.T) {
t.Parallel()
client1, _, api := coderdtest.NewWithAPI(t, &coderdtest.Options{})
u1 := coderdtest.CreateFirstUser(t, client1)
client2, u2 := coderdtest.CreateAnotherUser(t, client1, u1.OrganizationID)
db := api.Database
version := dbfake.TemplateVersion(t, db).Seed(database.TemplateVersion{
CreatedBy: u1.UserID,
OrganizationID: u1.OrganizationID,
}).Params(database.TemplateVersionParameter{
Name: "param",
Required: true,
},
database.TemplateVersionParameter{
Name: "param2",
Ephemeral: true,
},
).Do()
dbfake.WorkspaceBuild(t, db, database.Workspace{
OwnerID: u2.ID,
TemplateID: version.Template.ID,
OrganizationID: u1.OrganizationID,
}).Params(
database.WorkspaceBuildParameter{
Name: "param",
Value: "foo",
},
database.WorkspaceBuildParameter{
Name: "param2",
Value: "bar",
},
).Do()
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// Use client2 since client1 is site admin, so
// we don't get good coverage on RBAC working.
params, err := client2.UserAutofillParameters(
ctx,
u2.ID.String(),
version.Template.ID,
)
require.NoError(t, err)
require.Equal(t, 1, len(params))
require.Equal(t, "param", params[0].Name)
require.Equal(t, "foo", params[0].Value)
// Verify that latest parameter value is returned.
dbfake.WorkspaceBuild(t, db, database.Workspace{
OrganizationID: u1.OrganizationID,
OwnerID: u2.ID,
TemplateID: version.Template.ID,
}).Params(
database.WorkspaceBuildParameter{
Name: "param",
Value: "foo_new",
},
).Do()
params, err = client2.UserAutofillParameters(
ctx,
u2.ID.String(),
version.Template.ID,
)
require.NoError(t, err)
require.Equal(t, 1, len(params))
require.Equal(t, "param", params[0].Name)
require.Equal(t, "foo_new", params[0].Value)
})
}
// TestPaginatedUsers creates a list of users, then tries to paginate through
// them using different page sizes.
func TestPaginatedUsers(t *testing.T) {

View File

@ -3,29 +3,30 @@ package codersdk
type RBACResource string
const (
ResourceWorkspace RBACResource = "workspace"
ResourceWorkspaceProxy RBACResource = "workspace_proxy"
ResourceWorkspaceExecution RBACResource = "workspace_execution"
ResourceWorkspaceApplicationConnect RBACResource = "application_connect"
ResourceAuditLog RBACResource = "audit_log"
ResourceTemplate RBACResource = "template"
ResourceGroup RBACResource = "group"
ResourceFile RBACResource = "file"
ResourceProvisionerDaemon RBACResource = "provisioner_daemon"
ResourceOrganization RBACResource = "organization"
ResourceRoleAssignment RBACResource = "assign_role"
ResourceOrgRoleAssignment RBACResource = "assign_org_role"
ResourceAPIKey RBACResource = "api_key"
ResourceUser RBACResource = "user"
ResourceUserData RBACResource = "user_data"
ResourceOrganizationMember RBACResource = "organization_member"
ResourceLicense RBACResource = "license"
ResourceDeploymentValues RBACResource = "deployment_config"
ResourceDeploymentStats RBACResource = "deployment_stats"
ResourceReplicas RBACResource = "replicas"
ResourceDebugInfo RBACResource = "debug_info"
ResourceSystem RBACResource = "system"
ResourceTemplateInsights RBACResource = "template_insights"
ResourceWorkspace RBACResource = "workspace"
ResourceWorkspaceProxy RBACResource = "workspace_proxy"
ResourceWorkspaceExecution RBACResource = "workspace_execution"
ResourceWorkspaceApplicationConnect RBACResource = "application_connect"
ResourceAuditLog RBACResource = "audit_log"
ResourceTemplate RBACResource = "template"
ResourceGroup RBACResource = "group"
ResourceFile RBACResource = "file"
ResourceProvisionerDaemon RBACResource = "provisioner_daemon"
ResourceOrganization RBACResource = "organization"
ResourceRoleAssignment RBACResource = "assign_role"
ResourceOrgRoleAssignment RBACResource = "assign_org_role"
ResourceAPIKey RBACResource = "api_key"
ResourceUser RBACResource = "user"
ResourceUserData RBACResource = "user_data"
ResourceUserWorkspaceBuildParameters RBACResource = "user_workspace_build_parameters"
ResourceOrganizationMember RBACResource = "organization_member"
ResourceLicense RBACResource = "license"
ResourceDeploymentValues RBACResource = "deployment_config"
ResourceDeploymentStats RBACResource = "deployment_stats"
ResourceReplicas RBACResource = "replicas"
ResourceDebugInfo RBACResource = "debug_info"
ResourceSystem RBACResource = "system"
ResourceTemplateInsights RBACResource = "template_insights"
)
const (
@ -52,6 +53,7 @@ var (
ResourceAPIKey,
ResourceUser,
ResourceUserData,
ResourceUserWorkspaceBuildParameters,
ResourceOrganizationMember,
ResourceLicense,
ResourceDeploymentValues,

View File

@ -221,6 +221,27 @@ type OIDCAuthMethod struct {
IconURL string `json:"iconUrl"`
}
type UserParameter struct {
Name string `json:"name"`
Value string `json:"value"`
}
// UserAutofillParameters returns all recently used parameters for the given user.
func (c *Client) UserAutofillParameters(ctx context.Context, user string, templateID uuid.UUID) ([]UserParameter, error) {
res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/autofill-parameters?template_id=%s", user, templateID), nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, ReadBodyAsError(res)
}
var params []UserParameter
return params, json.NewDecoder(res.Body).Decode(&params)
}
// HasFirstUser returns whether the first user has been created.
func (c *Client) HasFirstUser(ctx context.Context) (bool, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/users/first", nil)

67
docs/api/schemas.md generated
View File

@ -4213,31 +4213,32 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
#### Enumerated Values
| Value |
| --------------------- |
| `workspace` |
| `workspace_proxy` |
| `workspace_execution` |
| `application_connect` |
| `audit_log` |
| `template` |
| `group` |
| `file` |
| `provisioner_daemon` |
| `organization` |
| `assign_role` |
| `assign_org_role` |
| `api_key` |
| `user` |
| `user_data` |
| `organization_member` |
| `license` |
| `deployment_config` |
| `deployment_stats` |
| `replicas` |
| `debug_info` |
| `system` |
| `template_insights` |
| Value |
| --------------------------------- |
| `workspace` |
| `workspace_proxy` |
| `workspace_execution` |
| `application_connect` |
| `audit_log` |
| `template` |
| `group` |
| `file` |
| `provisioner_daemon` |
| `organization` |
| `assign_role` |
| `assign_org_role` |
| `api_key` |
| `user` |
| `user_data` |
| `user_workspace_build_parameters` |
| `organization_member` |
| `license` |
| `deployment_config` |
| `deployment_stats` |
| `replicas` |
| `debug_info` |
| `system` |
| `template_insights` |
## codersdk.RateLimitConfig
@ -5832,6 +5833,22 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| ------------ | ---------------------------------------- | -------- | ------------ | ----------- |
| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | |
## codersdk.UserParameter
```json
{
"name": "string",
"value": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ------- | ------ | -------- | ------------ | ----------- |
| `name` | string | false | | |
| `value` | string | false | | |
## codersdk.UserQuietHoursScheduleConfig
```json

51
docs/api/users.md generated
View File

@ -513,6 +513,57 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/appearance \
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get autofill build parameters for user
### Code samples
```shell
# Example request using curl
curl -X GET http://coder-server:8080/api/v2/users/{user}/autofill-parameters?template_id=string \
-H 'Accept: application/json' \
-H 'Coder-Session-Token: API_KEY'
```
`GET /users/{user}/autofill-parameters`
### Parameters
| Name | In | Type | Required | Description |
| ------------- | ----- | ------ | -------- | ------------------------ |
| `user` | path | string | true | User ID, username, or me |
| `template_id` | query | string | true | Template ID |
### Example responses
> 200 Response
```json
[
{
"name": "string",
"value": "string"
}
]
```
### Responses
| Status | Meaning | Description | Schema |
| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------------- |
| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.UserParameter](schemas.md#codersdkuserparameter) |
<h3 id="get-autofill-build-parameters-for-user-responseschema">Response Schema</h3>
Status Code **200**
| Name | Type | Required | Restrictions | Description |
| -------------- | ------ | -------- | ------------ | ----------- |
| `[array item]` | array | false | | |
| `» name` | string | false | | |
| `» value` | string | false | | |
To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get user Git SSH key
### Code samples

View File

@ -177,14 +177,14 @@
"title": "Resource metadata",
"description": "Show information in the workspace about template resources",
"path": "./templates/resource-metadata.md"
},
{
"title": "Parameters",
"description": "Prompt the user for additional information about a workspace",
"path": "./templates/parameters.md"
}
]
},
{
"title": "Parameters",
"description": "Prompt the user for additional information about a workspace",
"path": "./templates/parameters.md"
},
{
"title": "Administering templates",
"description": "Configuration settings for template admins",

View File

@ -281,3 +281,15 @@ variable "CLOUD_API_KEY" {
}
```
## Create Autofill
When the template doesn't specify default values, Coder may still autofill
parameters.
1. Coder will look for URL query parameters with form `param.<name>=<value>`.
This feature enables platform teams to create pre-filled template creation
links.
2. Coder will populate recently used parameter key-value pairs for the user.
This feature helps reduce repetition when filling common parameters such as
`dotfiles_url` or `region`.

View File

@ -1,7 +1,10 @@
// Default port from the server
export const defaultPort = 3000;
export const prometheusPort = 2114;
export const pprofPort = 6061;
// Use alternate ports in case we're running in a Coder Workspace.
export const agentPProfPort = 6061;
export const coderdPProfPort = 6062;
// Credentials for the first user
export const username = "admin";

View File

@ -15,7 +15,7 @@ import {
Resource,
RichParameter,
} from "./provisionerGenerated";
import { prometheusPort, pprofPort } from "./constants";
import { prometheusPort, agentPProfPort } from "./constants";
import { port } from "./playwright.config";
import * as ssh from "ssh2";
import { Duplex } from "stream";
@ -306,7 +306,7 @@ export const startAgentWithCommand = async (
...process.env,
CODER_AGENT_URL: "http://localhost:" + port,
CODER_AGENT_TOKEN: token,
CODER_AGENT_PPROF_ADDRESS: "127.0.0.1:" + pprofPort,
CODER_AGENT_PPROF_ADDRESS: "127.0.0.1:" + agentPProfPort,
CODER_AGENT_PROMETHEUS_ADDRESS: "127.0.0.1:" + prometheusPort,
},
});

View File

@ -128,6 +128,14 @@ export const seventhParameter: RichParameter = {
order: 1,
};
// randParamName returns a new parameter with a random name.
// It helps to avoid cross-test interference when user-auto-fill triggers on
// the same parameter name.
export const randParamName = (p: RichParameter): RichParameter => {
const name = p.name + "_" + Math.random().toString(36).substring(7);
return { ...p, name: name };
};
// Build options
export const firstBuildOption: RichParameter = {

View File

@ -1,6 +1,6 @@
import { defineConfig } from "@playwright/test";
import path from "path";
import { defaultPort, gitAuth } from "./constants";
import { defaultPort, coderdPProfPort, gitAuth } from "./constants";
export const port = process.env.CODER_E2E_PORT
? Number(process.env.CODER_E2E_PORT)
@ -103,6 +103,7 @@ export default defineConfig({
gitAuth.webPort,
gitAuth.validatePath,
),
CODER_PPROF_ADDRESS: "127.0.0.1:" + coderdPProfPort,
},
reuseExistingServer: false,
},

View File

@ -119,7 +119,7 @@ const filteredServerLogLines = (chunk: string): string[] =>
const exportDebugPprof = async (outputFile: string) => {
const response = await axios.get(
"http://127.0.0.1:6060/debug/pprof/goroutine?debug=1",
"http://127.0.0.1:6062/debug/pprof/goroutine?debug=1",
);
if (response.status !== 200) {
throw new Error(`Error: Received status code ${response.status}`);

View File

@ -14,6 +14,7 @@ import {
thirdParameter,
seventhParameter,
sixthParameter,
randParamName,
} from "../parameters";
import { RichParameter } from "../provisionerGenerated";
import { beforeCoderTest } from "../hooks";
@ -101,10 +102,16 @@ test("create workspace with default and required parameters", async ({
});
test("create workspace and overwrite default parameters", async ({ page }) => {
const richParameters: RichParameter[] = [secondParameter, fourthParameter];
// We use randParamName to prevent the new values from corrupting user_history
// and thus affecting other tests.
const richParameters: RichParameter[] = [
randParamName(secondParameter),
randParamName(fourthParameter),
];
const buildParameters = [
{ name: secondParameter.name, value: "AAAAA" },
{ name: fourthParameter.name, value: "false" },
{ name: richParameters[0].name, value: "AAAAA" },
{ name: richParameters[1].name, value: "false" },
];
const template = await createTemplate(
page,

View File

@ -23,14 +23,14 @@ test("restart workspace with ephemeral parameters", async ({ page }) => {
// Verify that build options are default (not selected).
await verifyParameters(page, workspaceName, richParameters, [
{ name: firstBuildOption.name, value: firstBuildOption.defaultValue },
{ name: secondBuildOption.name, value: secondBuildOption.defaultValue },
{ name: richParameters[0].name, value: firstBuildOption.defaultValue },
{ name: richParameters[1].name, value: secondBuildOption.defaultValue },
]);
// Now, restart the workspace with ephemeral parameters selected.
const buildParameters = [
{ name: firstBuildOption.name, value: "AAAAA" },
{ name: secondBuildOption.name, value: "true" },
{ name: richParameters[0].name, value: "AAAAA" },
{ name: richParameters[1].name, value: "true" },
];
await buildWorkspaceWithParameters(
page,
@ -42,7 +42,7 @@ test("restart workspace with ephemeral parameters", async ({ page }) => {
// Verify that build options are default (not selected).
await verifyParameters(page, workspaceName, richParameters, [
{ name: firstBuildOption.name, value: firstBuildOption.defaultValue },
{ name: secondBuildOption.name, value: secondBuildOption.defaultValue },
{ name: richParameters[0].name, value: firstBuildOption.defaultValue },
{ name: richParameters[1].name, value: secondBuildOption.defaultValue },
]);
});

View File

@ -21,8 +21,8 @@ test("start workspace with ephemeral parameters", async ({ page }) => {
// Verify that build options are default (not selected).
await verifyParameters(page, workspaceName, richParameters, [
{ name: firstBuildOption.name, value: firstBuildOption.defaultValue },
{ name: secondBuildOption.name, value: secondBuildOption.defaultValue },
{ name: richParameters[0].name, value: firstBuildOption.defaultValue },
{ name: richParameters[1].name, value: secondBuildOption.defaultValue },
]);
// Stop the workspace
@ -30,8 +30,8 @@ test("start workspace with ephemeral parameters", async ({ page }) => {
// Now, start the workspace with ephemeral parameters selected.
const buildParameters = [
{ name: firstBuildOption.name, value: "AAAAA" },
{ name: secondBuildOption.name, value: "true" },
{ name: richParameters[0].name, value: "AAAAA" },
{ name: richParameters[1].name, value: "true" },
];
await buildWorkspaceWithParameters(
@ -43,7 +43,7 @@ test("start workspace with ephemeral parameters", async ({ page }) => {
// Verify that build options are default (not selected).
await verifyParameters(page, workspaceName, richParameters, [
{ name: firstBuildOption.name, value: firstBuildOption.defaultValue },
{ name: secondBuildOption.name, value: secondBuildOption.defaultValue },
{ name: richParameters[0].name, value: firstBuildOption.defaultValue },
{ name: richParameters[1].name, value: secondBuildOption.defaultValue },
]);
});

View File

@ -129,6 +129,13 @@ export const getAuthenticatedUser = async () => {
return response.data;
};
export const getUserParameters = async (templateID: string) => {
const response = await axios.get<TypesGen.UserParameter[]>(
"/api/v2/users/me/autofill-parameters?template_id=" + templateID,
);
return response.data;
};
export const getAuthMethods = async (): Promise<TypesGen.AuthMethods> => {
const response = await axios.get<TypesGen.AuthMethods>(
"/api/v2/users/authmethods",

View File

@ -1435,6 +1435,12 @@ export interface UserLoginType {
readonly login_type: LoginType;
}
// From codersdk/users.go
export interface UserParameter {
readonly name: string;
readonly value: string;
}
// From codersdk/deployment.go
export interface UserQuietHoursScheduleConfig {
readonly default_schedule: string;
@ -2011,6 +2017,7 @@ export type RBACResource =
| "template_insights"
| "user"
| "user_data"
| "user_workspace_build_parameters"
| "workspace"
| "workspace_execution"
| "workspace_proxy";
@ -2035,6 +2042,7 @@ export const RBACResources: RBACResource[] = [
"template_insights",
"user",
"user_data",
"user_workspace_build_parameters",
"workspace",
"workspace_execution",
"workspace_proxy",

View File

@ -10,6 +10,7 @@ import { MemoizedMarkdown } from "components/Markdown/Markdown";
import { Stack } from "components/Stack/Stack";
import { MultiTextField } from "./MultiTextField";
import { ExternalImage } from "components/ExternalImage/ExternalImage";
import { AutofillSource } from "utils/richParameters";
import { Pill } from "components/Pill/Pill";
import ErrorOutline from "@mui/icons-material/ErrorOutline";
@ -167,6 +168,7 @@ export type RichParameterInputProps = Omit<
"size" | "onChange"
> & {
parameter: TemplateVersionParameter;
autofillSource?: AutofillSource;
onChange: (value: string) => void;
size?: Size;
};
@ -174,6 +176,7 @@ export type RichParameterInputProps = Omit<
export const RichParameterInput: FC<RichParameterInputProps> = ({
parameter,
size = "medium",
autofillSource,
...fieldProps
}) => {
return (
@ -186,6 +189,17 @@ export const RichParameterInput: FC<RichParameterInputProps> = ({
<ParameterLabel parameter={parameter} />
<div css={{ display: "flex", flexDirection: "column" }}>
<RichParameterField {...fieldProps} size={size} parameter={parameter} />
{autofillSource && autofillSource !== "active_build" && (
<div css={{ marginTop: 4, fontSize: 12 }}>
🪄 Autofilled:{" "}
{
{
["url"]: "value supplied by URL.",
["user_history"]: "recently used value.",
}[autofillSource]
}
</div>
)}
</div>
</Stack>
);

View File

@ -1,13 +1,15 @@
import { TemplateVersionParameter } from "api/typesGenerated";
import { FormSection, FormFields } from "components/Form/Form";
import { FormFields, FormSection } from "components/Form/Form";
import {
RichParameterInput,
RichParameterInputProps,
} from "components/RichParameterInput/RichParameterInput";
import { ComponentProps, FC } from "react";
import { AutofillSource } from "utils/richParameters";
export type TemplateParametersSectionProps = {
templateParameters: TemplateVersionParameter[];
autofillSources?: Record<string, AutofillSource>;
getInputProps: (
parameter: TemplateVersionParameter,
index: number,
@ -17,6 +19,7 @@ export type TemplateParametersSectionProps = {
export const TemplateParametersSection: FC<TemplateParametersSectionProps> = ({
templateParameters,
getInputProps,
autofillSources,
...formSectionProps
}) => {
const hasMutableParameters =
@ -38,6 +41,9 @@ export const TemplateParametersSection: FC<TemplateParametersSectionProps> = ({
{...getInputProps(parameter, index)}
key={parameter.name}
parameter={parameter}
autofillSource={
autofillSources && autofillSources[parameter.name]
}
/>
),
)}

View File

@ -176,7 +176,9 @@ describe("CreateWorkspacePage", () => {
"me",
expect.objectContaining({
template_id: MockTemplate.id,
rich_parameter_values: [{ name: param, value: paramValue }],
rich_parameter_values: [
expect.objectContaining({ name: param, value: paramValue }),
],
}),
);
});
@ -201,7 +203,9 @@ describe("CreateWorkspacePage", () => {
"me",
expect.objectContaining({
template_version_id: MockTemplate.active_version_id,
rich_parameter_values: [{ name: param, value: paramValue }],
rich_parameter_values: [
expect.objectContaining({ name: param, value: paramValue }),
],
}),
);
});

View File

@ -1,34 +1,36 @@
import { type FC, useCallback, useState, useEffect, useMemo } from "react";
import { Helmet } from "react-helmet-async";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
uniqueNamesGenerator,
animals,
colors,
NumberDictionary,
} from "unique-names-generator";
import type {
TemplateVersionParameter,
Workspace,
WorkspaceBuildParameter,
} from "api/typesGenerated";
import { useMe } from "contexts/auth/useMe";
import { useOrganizationId } from "contexts/auth/useOrganizationId";
import { pageTitle } from "utils/page";
import { getUserParameters } from "api/api";
import { checkAuthorization } from "api/queries/authCheck";
import {
richParameters,
templateByName,
templateVersionExternalAuth,
richParameters,
} from "api/queries/templates";
import { autoCreateWorkspace, createWorkspace } from "api/queries/workspaces";
import { useEffectEvent } from "hooks/hookPolyfills";
import { paramsUsedToCreateWorkspace } from "utils/workspace";
import { Loader } from "components/Loader/Loader";
import {
TemplateVersionParameter,
UserParameter,
Workspace,
} from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { CreateWSPermissions, createWorkspaceChecks } from "./permissions";
import { Loader } from "components/Loader/Loader";
import { useMe } from "contexts/auth/useMe";
import { useOrganizationId } from "contexts/auth/useOrganizationId";
import { useEffectEvent } from "hooks/hookPolyfills";
import { useCallback, useEffect, useMemo, useState, type FC } from "react";
import { Helmet } from "react-helmet-async";
import { useMutation, useQuery, useQueryClient } from "react-query";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
NumberDictionary,
animals,
colors,
uniqueNamesGenerator,
} from "unique-names-generator";
import { pageTitle } from "utils/page";
import { AutofillBuildParameter } from "utils/richParameters";
import { paramsUsedToCreateWorkspace } from "utils/workspace";
import { CreateWorkspacePageView } from "./CreateWorkspacePageView";
import { CreateWSPermissions, createWorkspaceChecks } from "./permissions";
export const createWorkspaceModes = ["form", "auto", "duplicate"] as const;
export type CreateWorkspaceMode = (typeof createWorkspaceModes)[number];
@ -41,7 +43,6 @@ const CreateWorkspacePage: FC = () => {
const me = useMe();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const defaultBuildParameters = getDefaultBuildParameters(searchParams);
const mode = getWorkspaceMode(searchParams);
const customVersionId = searchParams.get("version") ?? undefined;
@ -61,6 +62,15 @@ const CreateWorkspacePage: FC = () => {
const createWorkspaceMutation = useMutation(createWorkspace(queryClient));
const templateQuery = useQuery(templateByName(organizationId, templateName));
const userParametersQuery = useQuery(
["userParameters"],
() => getUserParameters(templateQuery.data!.id),
{
enabled: templateQuery.isSuccess,
},
);
const permissionsQuery = useQuery(
checkAuthorization({
checks: createWorkspaceChecks(organizationId),
@ -101,12 +111,17 @@ const CreateWorkspacePage: FC = () => {
[navigate],
);
const autofillParameters = getAutofillParameters(
searchParams,
userParametersQuery.data ? userParametersQuery.data : [],
);
const automateWorkspaceCreation = useEffectEvent(async () => {
try {
const newWorkspace = await autoCreateWorkspaceMutation.mutateAsync({
templateName,
organizationId,
defaultBuildParameters,
defaultBuildParameters: autofillParameters,
defaultName,
versionId: realizedVersionId,
});
@ -139,7 +154,7 @@ const CreateWorkspacePage: FC = () => {
mode={mode}
defaultName={defaultName}
defaultOwner={me}
defaultBuildParameters={defaultBuildParameters}
autofillParameters={autofillParameters}
error={createWorkspaceMutation.error}
resetMutation={createWorkspaceMutation.reset}
template={templateQuery.data!}
@ -223,17 +238,34 @@ const useExternalAuth = (versionId: string | undefined) => {
};
};
const getDefaultBuildParameters = (
const getAutofillParameters = (
urlSearchParams: URLSearchParams,
): WorkspaceBuildParameter[] => {
const buildValues: WorkspaceBuildParameter[] = [];
Array.from(urlSearchParams.keys())
userParameters: UserParameter[],
): AutofillBuildParameter[] => {
const userParamMap = userParameters.reduce((acc, param) => {
acc.set(param.name, param);
return acc;
}, new Map<string, UserParameter>());
const buildValues: AutofillBuildParameter[] = Array.from(
urlSearchParams.keys(),
)
.filter((key) => key.startsWith("param."))
.forEach((key) => {
.map((key) => {
const name = key.replace("param.", "");
const value = urlSearchParams.get(key) ?? "";
buildValues.push({ name, value });
// URL should take precedence over user parameters
userParamMap.delete(name);
return { name, value, source: "url" };
});
userParamMap.forEach((param) => {
buildValues.push({
name: param.name,
value: param.value,
source: "user_history",
});
});
return buildValues;
};

View File

@ -17,7 +17,7 @@ const meta: Meta<typeof CreateWorkspacePageView> = {
args: {
defaultName: "",
defaultOwner: MockUser,
defaultBuildParameters: [],
autofillParameters: [],
template: MockTemplate,
parameters: [],
externalAuth: [],
@ -86,6 +86,13 @@ export const Parameters: Story = {
ephemeral: false,
},
],
autofillParameters: [
{
name: "first_parameter",
value: "It works!",
source: "user_history",
},
],
},
};

View File

@ -3,7 +3,7 @@ import TextField from "@mui/material/TextField";
import type * as TypesGen from "api/typesGenerated";
import { UserAutocomplete } from "components/UserAutocomplete/UserAutocomplete";
import { FormikContextType, useFormik } from "formik";
import { type FC, useEffect, useState } from "react";
import { type FC, useEffect, useState, useMemo } from "react";
import {
getFormHelpers,
nameValidator,
@ -17,6 +17,8 @@ import {
HorizontalForm,
} from "components/Form/Form";
import {
AutofillBuildParameter,
AutofillSource,
getInitialRichParameterValues,
useValidationSchemaForRichParameters,
} from "utils/richParameters";
@ -58,7 +60,7 @@ export interface CreateWorkspacePageViewProps {
externalAuthPollingState: ExternalAuthPollingState;
startPollingExternalAuth: () => void;
parameters: TypesGen.TemplateVersionParameter[];
defaultBuildParameters: TypesGen.WorkspaceBuildParameter[];
autofillParameters: AutofillBuildParameter[];
permissions: CreateWSPermissions;
creatingWorkspace: boolean;
onCancel: () => void;
@ -80,7 +82,7 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
externalAuthPollingState,
startPollingExternalAuth,
parameters,
defaultBuildParameters,
autofillParameters,
permissions,
creatingWorkspace,
onSubmit,
@ -98,7 +100,7 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
template_id: template.id,
rich_parameter_values: getInitialRichParameterValues(
parameters,
defaultBuildParameters,
autofillParameters,
),
},
validationSchema: Yup.object({
@ -126,6 +128,16 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
error,
);
const autofillSources = useMemo(() => {
return autofillParameters.reduce(
(acc, param) => {
acc[param.name] = param.source;
return acc;
},
{} as Record<string, AutofillSource>,
);
}, [autofillParameters]);
return (
<Margins size="medium">
<PageHeader actions={<Button onClick={onCancel}>Cancel</Button>}>
@ -244,6 +256,7 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
value,
});
}}
autofillSource={autofillSources[parameter.name]}
key={parameter.name}
parameter={parameter}
disabled={isDisabled}

View File

@ -21,7 +21,10 @@ import {
import { useFormik } from "formik";
import { docs } from "utils/docs";
import { getFormHelpers } from "utils/formUtils";
import { getInitialRichParameterValues } from "utils/richParameters";
import {
AutofillBuildParameter,
getInitialRichParameterValues,
} from "utils/richParameters";
import {
Popover,
PopoverContent,
@ -113,7 +116,12 @@ const BuildParametersPopoverContent: FC<BuildParametersPopoverContentProps> = ({
popover.setIsOpen(false);
}}
ephemeralParameters={ephemeralParameters}
buildParameters={buildParameters}
buildParameters={buildParameters.map(
(p): AutofillBuildParameter => ({
...p,
source: "active_build",
}),
)}
/>
</div>
</>
@ -147,7 +155,7 @@ const BuildParametersPopoverContent: FC<BuildParametersPopoverContentProps> = ({
interface FormProps {
ephemeralParameters: TemplateVersionParameter[];
buildParameters: WorkspaceBuildParameter[];
buildParameters: AutofillBuildParameter[];
onSubmit: (buildParameters: WorkspaceBuildParameter[]) => void;
}

View File

@ -10,6 +10,7 @@ import {
} from "components/Form/Form";
import { RichParameterInput } from "components/RichParameterInput/RichParameterInput";
import {
AutofillBuildParameter,
getInitialRichParameterValues,
useValidationSchemaForRichParameters,
} from "utils/richParameters";
@ -27,7 +28,7 @@ export type WorkspaceParametersFormValues = {
interface WorkspaceParameterFormProps {
workspace: Workspace;
templateVersionRichParameters: TemplateVersionParameter[];
buildParameters: WorkspaceBuildParameter[];
autofillParams: AutofillBuildParameter[];
isSubmitting: boolean;
canChangeVersions: boolean;
error: unknown;
@ -40,7 +41,7 @@ export const WorkspaceParametersForm: FC<WorkspaceParameterFormProps> = ({
onCancel,
onSubmit,
templateVersionRichParameters,
buildParameters,
autofillParams,
error,
canChangeVersions,
isSubmitting,
@ -50,7 +51,7 @@ export const WorkspaceParametersForm: FC<WorkspaceParameterFormProps> = ({
initialValues: {
rich_parameter_values: getInitialRichParameterValues(
templateVersionRichParameters,
buildParameters,
autofillParams,
),
},
validationSchema: Yup.object({

View File

@ -24,6 +24,7 @@ import { EmptyState } from "components/EmptyState/EmptyState";
import Button from "@mui/material/Button";
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
import { docs } from "utils/docs";
import { AutofillBuildParameter } from "utils/richParameters";
const WorkspaceParametersPage: FC = () => {
const workspace = useWorkspaceSettings();
@ -126,7 +127,12 @@ export const WorkspaceParametersPageView: FC<
<WorkspaceParametersForm
workspace={workspace}
canChangeVersions={canChangeVersions}
buildParameters={data.buildParameters}
autofillParams={data.buildParameters.map(
(p): AutofillBuildParameter => ({
...p,
source: "active_build",
}),
)}
templateVersionRichParameters={data.templateVersionRichParameters}
error={submitError}
isSubmitting={isSubmitting}

View File

@ -46,7 +46,7 @@ test("getInitialRichParameterValues return default value when default build para
const cpuParameter = templateParameters[0];
const [cpuParameterInitialValue] = getInitialRichParameterValues(
templateParameters,
[{ name: cpuParameter.name, value: "100" }],
[{ name: cpuParameter.name, value: "100", source: "user_history" }],
);
expect(cpuParameterInitialValue.value).toBe(cpuParameter.default_value);

View File

@ -4,25 +4,39 @@ import {
} from "api/typesGenerated";
import * as Yup from "yup";
export type AutofillSource = "user_history" | "url" | "active_build";
// AutofillBuildParameter is a build parameter destined to a form, alongside
// its source so that the form can explain where the value comes from.
export type AutofillBuildParameter = {
source: AutofillSource;
} & WorkspaceBuildParameter;
export const getInitialRichParameterValues = (
templateParameters: TemplateVersionParameter[],
buildParameters?: WorkspaceBuildParameter[],
templateParams: TemplateVersionParameter[],
autofillParams?: AutofillBuildParameter[],
): WorkspaceBuildParameter[] => {
return templateParameters.map((parameter) => {
const existentBuildParameter = buildParameters?.find(
(p) => p.name === parameter.name,
);
const shouldReturnTheDefaultValue =
!existentBuildParameter ||
!isValidValue(parameter, existentBuildParameter) ||
parameter.ephemeral;
if (shouldReturnTheDefaultValue) {
return templateParams.map((parameter) => {
// Short-circuit for ephemeral parameters, which are always reset to
// the template-defined default.
if (parameter.ephemeral) {
return {
name: parameter.name,
value: parameter.default_value,
};
}
return existentBuildParameter;
const autofillParam = autofillParams?.find(
({ name }) => name === parameter.name,
);
return {
name: parameter.name,
value:
autofillParam && isValidValue(parameter, autofillParam)
? autofillParam.value
: parameter.default_value,
};
});
};