diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 85d68f7ad21..6478e5f5e9e 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -145,6 +145,7 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate arg.FuzzyDisplayName, pq.Array(arg.IDs), arg.Deprecated, + arg.UseClassicParameterFlow, arg.AgentsAllowed, arg.AuthorID, arg.AuthorUsername, diff --git a/coderd/database/querier_test.go b/coderd/database/querier_test.go index b630a4278a2..5b1f87ab83a 100644 --- a/coderd/database/querier_test.go +++ b/coderd/database/querier_test.go @@ -1055,6 +1055,63 @@ func TestGetWorkspaceAgentUsageStats(t *testing.T) { }) } +//nolint:tparallel,paralleltest // Subtests share one database seeded by the parent test. +func TestGetTemplatesWithUseClassicParameterFlowFilter(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitMedium) + org := dbgen.Organization(t, db, database.Organization{}) + user := dbgen.User(t, db, database.User{}) + classic := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + UseClassicParameterFlow: true, + }) + dynamic := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + CreatedBy: user.ID, + UseClassicParameterFlow: false, + }) + + tests := []struct { + name string + value sql.NullBool + want []uuid.UUID + }{ + { + name: "unset", + want: []uuid.UUID{classic.ID, dynamic.ID}, + }, + { + name: "classic", + value: sql.NullBool{Bool: true, Valid: true}, + want: []uuid.UUID{classic.ID}, + }, + { + name: "dynamic", + value: sql.NullBool{Bool: false, Valid: true}, + want: []uuid.UUID{dynamic.ID}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := db.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{ + Deleted: false, + OrganizationID: org.ID, + UseClassicParameterFlow: tt.value, + }) + require.NoError(t, err) + gotIDs := make([]uuid.UUID, 0, len(got)) + for _, template := range got { + gotIDs = append(gotIDs, template.ID) + } + require.ElementsMatch(t, tt.want, gotIDs) + }) + } +} + //nolint:tparallel,paralleltest // Subtests share one database seeded by the parent test. func TestGetTemplatesWithAgentsAllowedFilter(t *testing.T) { t.Parallel() diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 36b4696588c..a896014e6ce 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -27653,29 +27653,35 @@ WHERE END ELSE true END - -- Filter by agents_allowed + -- Filter by classic parameter flow AND CASE WHEN $9 :: boolean IS NOT NULL THEN - t.agents_allowed = $9 :: boolean + t.use_classic_parameter_flow = $9 :: boolean + ELSE true + END + -- Filter by agents_allowed + AND CASE + WHEN $10 :: boolean IS NOT NULL THEN + t.agents_allowed = $10 :: boolean ELSE true END -- Filter by author_id AND CASE - WHEN $10 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN - t.created_by = $10 + WHEN $11 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + t.created_by = $11 ELSE true END -- Filter by author_username AND CASE - WHEN $11 :: text != '' THEN - t.created_by = (SELECT id FROM users WHERE lower(users.username) = lower($11) AND deleted = false) + WHEN $12 :: text != '' THEN + t.created_by = (SELECT id FROM users WHERE lower(users.username) = lower($12) AND deleted = false) ELSE true END -- Filter by has_external_agent in latest version AND CASE - WHEN $12 :: boolean IS NOT NULL THEN - tv.has_external_agent = $12 :: boolean + WHEN $13 :: boolean IS NOT NULL THEN + tv.has_external_agent = $13 :: boolean ELSE true END -- Authorize Filter clause will be injected below in GetAuthorizedTemplates @@ -27684,18 +27690,19 @@ ORDER BY (t.name, t.id) ASC ` type GetTemplatesWithFilterParams struct { - Deleted bool `db:"deleted" json:"deleted"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - ExactName string `db:"exact_name" json:"exact_name"` - ExactDisplayName string `db:"exact_display_name" json:"exact_display_name"` - FuzzyName string `db:"fuzzy_name" json:"fuzzy_name"` - FuzzyDisplayName string `db:"fuzzy_display_name" json:"fuzzy_display_name"` - IDs []uuid.UUID `db:"ids" json:"ids"` - Deprecated sql.NullBool `db:"deprecated" json:"deprecated"` - AgentsAllowed sql.NullBool `db:"agents_allowed" json:"agents_allowed"` - AuthorID uuid.UUID `db:"author_id" json:"author_id"` - AuthorUsername string `db:"author_username" json:"author_username"` - HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` + Deleted bool `db:"deleted" json:"deleted"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + ExactName string `db:"exact_name" json:"exact_name"` + ExactDisplayName string `db:"exact_display_name" json:"exact_display_name"` + FuzzyName string `db:"fuzzy_name" json:"fuzzy_name"` + FuzzyDisplayName string `db:"fuzzy_display_name" json:"fuzzy_display_name"` + IDs []uuid.UUID `db:"ids" json:"ids"` + Deprecated sql.NullBool `db:"deprecated" json:"deprecated"` + UseClassicParameterFlow sql.NullBool `db:"use_classic_parameter_flow" json:"use_classic_parameter_flow"` + AgentsAllowed sql.NullBool `db:"agents_allowed" json:"agents_allowed"` + AuthorID uuid.UUID `db:"author_id" json:"author_id"` + AuthorUsername string `db:"author_username" json:"author_username"` + HasExternalAgent sql.NullBool `db:"has_external_agent" json:"has_external_agent"` } func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplatesWithFilterParams) ([]Template, error) { @@ -27708,6 +27715,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate arg.FuzzyDisplayName, pq.Array(arg.IDs), arg.Deprecated, + arg.UseClassicParameterFlow, arg.AgentsAllowed, arg.AuthorID, arg.AuthorUsername, diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index d05c78a61ad..33636f69f54 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -71,6 +71,12 @@ WHERE END ELSE true END + -- Filter by classic parameter flow + AND CASE + WHEN sqlc.narg('use_classic_parameter_flow') :: boolean IS NOT NULL THEN + t.use_classic_parameter_flow = sqlc.narg('use_classic_parameter_flow') :: boolean + ELSE true + END -- Filter by agents_allowed AND CASE WHEN sqlc.narg('agents_allowed') :: boolean IS NOT NULL THEN diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 1d0d0cae50b..4e83d657654 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -366,18 +366,19 @@ func Templates(ctx context.Context, db database.Store, actorID uuid.UUID, query parser := httpapi.NewQueryParamParser() filter := database.GetTemplatesWithFilterParams{ - Deleted: parser.Boolean(values, false, "deleted"), - OrganizationID: parseOrganization(ctx, db, parser, values, "organization"), - ExactName: parser.String(values, "", "exact_name"), - ExactDisplayName: parser.String(values, "", "exact_display_name"), - FuzzyName: parser.String(values, "", "name"), - FuzzyDisplayName: parser.String(values, "", "display_name"), - IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"), - Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"), - AgentsAllowed: parser.NullableBoolean(values, sql.NullBool{}, "agents-allowed"), - AuthorID: parser.UUID(values, uuid.Nil, "author_id"), - AuthorUsername: parser.String(values, "", "author"), - HasExternalAgent: parser.NullableBoolean(values, sql.NullBool{}, "has_external_agent"), + Deleted: parser.Boolean(values, false, "deleted"), + OrganizationID: parseOrganization(ctx, db, parser, values, "organization"), + ExactName: parser.String(values, "", "exact_name"), + ExactDisplayName: parser.String(values, "", "exact_display_name"), + FuzzyName: parser.String(values, "", "name"), + FuzzyDisplayName: parser.String(values, "", "display_name"), + IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"), + Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"), + UseClassicParameterFlow: parser.NullableBoolean(values, sql.NullBool{}, "compatibility_mode"), + AgentsAllowed: parser.NullableBoolean(values, sql.NullBool{}, "agents-allowed"), + AuthorID: parser.UUID(values, uuid.Nil, "author_id"), + AuthorUsername: parser.String(values, "", "author"), + HasExternalAgent: parser.NullableBoolean(values, sql.NullBool{}, "has_external_agent"), } if filter.AuthorUsername == codersdk.Me { diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 5b2be044307..093631f4fa5 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -923,6 +923,27 @@ func TestSearchTemplates(t *testing.T) { FuzzyDisplayName: "foobar", }, }, + { + Name: "UseClassicParameterFlowTrue", + Query: "compatibility_mode:true", + Expected: database.GetTemplatesWithFilterParams{ + UseClassicParameterFlow: sql.NullBool{Bool: true, Valid: true}, + }, + }, + { + Name: "UseClassicParameterFlowFalse", + Query: "compatibility_mode:false", + Expected: database.GetTemplatesWithFilterParams{ + UseClassicParameterFlow: sql.NullBool{Bool: false, Valid: true}, + }, + }, + { + Name: "UseClassicParameterFlowMissing", + Query: "", + Expected: database.GetTemplatesWithFilterParams{ + UseClassicParameterFlow: sql.NullBool{Bool: false, Valid: false}, + }, + }, { Name: "HasExternalAgent", Query: "has_external_agent:true", diff --git a/coderd/templates_test.go b/coderd/templates_test.go index de3707cba23..4d822fba207 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -2440,6 +2440,47 @@ func TestTemplateNotifications(t *testing.T) { }) } +func TestTemplateFilterUseClassicParameterFlow(t *testing.T) { + t.Parallel() + + db, pubsub := dbtestutil.NewDB(t) + client := coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: pubsub, + IncludeProvisionerDaemon: true, + }) + user := coderdtest.CreateFirstUser(t, client) + classicVersion := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + classic := coderdtest.CreateTemplate(t, client, user.OrganizationID, classicVersion.ID, func(request *codersdk.CreateTemplateRequest) { + request.Name = "classic" + request.UseClassicParameterFlow = new(true) + }) + dynamicVersion := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + dynamic := coderdtest.CreateTemplate(t, client, user.OrganizationID, dynamicVersion.ID, func(request *codersdk.CreateTemplateRequest) { + request.Name = "dynamic" + request.UseClassicParameterFlow = new(false) + }) + + ctx := testutil.Context(t, testutil.WaitLong) + templates, err := client.Templates(ctx, codersdk.TemplateFilter{ + SearchQuery: "compatibility_mode:true", + }) + require.NoError(t, err) + require.Len(t, templates, 1) + require.Equal(t, classic.ID, templates[0].ID) + + templates, err = client.Templates(ctx, codersdk.TemplateFilter{ + SearchQuery: "compatibility_mode:false", + }) + require.NoError(t, err) + require.Len(t, templates, 1) + require.Equal(t, dynamic.ID, templates[0].ID) + + templates, err = client.Templates(ctx, codersdk.TemplateFilter{}) + require.NoError(t, err) + require.Len(t, templates, 2) +} + func TestTemplateFilterHasExternalAgent(t *testing.T) { t.Parallel() diff --git a/site/src/pages/TemplatesPage/TemplatesFilter.tsx b/site/src/pages/TemplatesPage/TemplatesFilter.tsx index 433f09ace02..5ef9a255c08 100644 --- a/site/src/pages/TemplatesPage/TemplatesFilter.tsx +++ b/site/src/pages/TemplatesPage/TemplatesFilter.tsx @@ -22,6 +22,8 @@ import { import { useAuthenticated } from "#/hooks/useAuthenticated"; import { useDashboard } from "#/modules/dashboard/useDashboard"; +export const CLASSIC_PARAMETER_FLOW_FILTER = "compatibility_mode:true"; + export type TemplateFilterState = { filter: UseFilterResult; menus: { @@ -101,6 +103,10 @@ export const TemplatesFilter: FC = ({ { query: "", name: "All templates" }, { query: "author:me", name: "Templates you authored" }, { query: "deprecated:true", name: "Deprecated templates" }, + { + query: CLASSIC_PARAMETER_FLOW_FILTER, + name: "Templates using compatibility mode", + }, ]} // TODO: Add docs for this // learnMoreLink={docs("/admin/templates#template-filtering")} diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index 170ce2cd64e..c37e65a0e53 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -55,13 +55,30 @@ import { formatTemplateBuildTime, } from "#/utils/templates"; import { EmptyTemplates } from "./EmptyTemplates"; -import { type TemplateFilterState, TemplatesFilter } from "./TemplatesFilter"; +import { + CLASSIC_PARAMETER_FLOW_FILTER, + type TemplateFilterState, + TemplatesFilter, +} from "./TemplatesFilter"; const ClassicParameterFlowAlert: FC<{ templateCount: number }> = ({ templateCount, }) => { return ( - + + + View templates + + + } + > {templateCount === 1 ? "1 template still uses classic parameters"