diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 99e2aaa0e5aa1..547e24a0f628c 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -7881,11 +7881,11 @@ WHERE LOWER("name") = LOWER($3) ELSE true END - -- Filter by name, matching on substring - AND CASE - WHEN $4 :: text != '' THEN - lower(name) ILIKE '%' || lower($4) || '%' - ELSE true + -- Filter by name, matching on substring + AND CASE + WHEN $4 :: text != '' THEN + lower(name) ILIKE '%' || lower($4) || '%' + ELSE true END -- Filter by ids AND CASE diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index a2bfa8ae01497..84df9633a1a53 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -28,11 +28,11 @@ WHERE LOWER("name") = LOWER(@exact_name) ELSE true END - -- Filter by name, matching on substring - AND CASE - WHEN @fuzzy_name :: text != '' THEN - lower(name) ILIKE '%' || lower(@fuzzy_name) || '%' - ELSE true + -- Filter by name, matching on substring + AND CASE + WHEN @fuzzy_name :: text != '' THEN + lower(name) ILIKE '%' || lower(@fuzzy_name) || '%' + ELSE true END -- Filter by ids AND CASE diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 78966a255de92..500eae2723336 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -198,9 +198,9 @@ func Templates(ctx context.Context, db database.Store, query string) (database.G parser := httpapi.NewQueryParamParser() filter := database.GetTemplatesWithFilterParams{ - FuzzyName: parser.String(values, "", "name"), Deleted: parser.Boolean(values, false, "deleted"), ExactName: parser.String(values, "", "exact_name"), + FuzzyName: parser.String(values, "", "name"), IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"), Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"), } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 5c21d64b9c4be..19040b0785e31 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -304,9 +304,17 @@ export type GetTemplatesOptions = Readonly<{ readonly deprecated?: boolean; }>; +export type GetTemplatesQuery = Readonly<{ + readonly q: string; +}>; + function normalizeGetTemplatesOptions( - options: GetTemplatesOptions = {}, + options: GetTemplatesOptions | GetTemplatesQuery = {}, ): Record { + if ("q" in options) { + return options; + } + const params: Record = {}; if (options.deprecated !== undefined) { params["deprecated"] = String(options.deprecated); @@ -666,6 +674,13 @@ class ApiMethods { return response.data; }; + getMyOrganizations = async (): Promise => { + const response = await this.axios.get( + "/api/v2/users/me/organizations", + ); + return response.data; + }; + /** * @param organization Can be the organization's ID or name */ @@ -687,7 +702,7 @@ class ApiMethods { }; getTemplates = async ( - options?: GetTemplatesOptions, + options?: GetTemplatesOptions | GetTemplatesQuery, ): Promise => { const params = normalizeGetTemplatesOptions(options); const response = await this.axios.get( diff --git a/site/src/api/queries/templates.ts b/site/src/api/queries/templates.ts index c6d0f0ac74c4d..7068613687911 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -1,5 +1,5 @@ import type { MutationOptions, QueryClient, QueryOptions } from "react-query"; -import { API, type GetTemplatesOptions } from "api/api"; +import { API, type GetTemplatesQuery, type GetTemplatesOptions } from "api/api"; import type { CreateTemplateRequest, CreateTemplateVersionRequest, @@ -38,12 +38,13 @@ export const templateByName = ( }; }; -const getTemplatesQueryKey = (options?: GetTemplatesOptions) => [ - "templates", - options?.deprecated, -]; +const getTemplatesQueryKey = ( + options?: GetTemplatesOptions | GetTemplatesQuery, +) => ["templates", options]; -export const templates = (options?: GetTemplatesOptions) => { +export const templates = ( + options?: GetTemplatesOptions | GetTemplatesQuery, +) => { return { queryKey: getTemplatesQueryKey(options), queryFn: () => API.getTemplates(options), diff --git a/site/src/components/Filter/filter.tsx b/site/src/components/Filter/filter.tsx index 29b34118408a4..fb36276571b92 100644 --- a/site/src/components/Filter/filter.tsx +++ b/site/src/components/Filter/filter.tsx @@ -137,7 +137,7 @@ type FilterProps = { filter: ReturnType; skeleton: ReactNode; isLoading: boolean; - learnMoreLink: string; + learnMoreLink?: string; learnMoreLabel2?: string; learnMoreLink2?: string; error?: unknown; @@ -240,7 +240,7 @@ export const Filter: FC = ({ interface PresetMenuProps { presets: PresetFilter[]; - learnMoreLink: string; + learnMoreLink?: string; learnMoreLabel2?: string; learnMoreLink2?: string; onSelect: (query: string) => void; @@ -293,19 +293,23 @@ const PresetMenu: FC = ({ {presetFilter.name} ))} - - { - setIsOpen(false); - }} - > - - View advanced filtering - + {learnMoreLink && ( + <> + + { + setIsOpen(false); + }} + > + + View advanced filtering + + + )} {learnMoreLink2 && learnMoreLabel2 && ( ; +} + +export const TemplatesFilter: FC = ({ filter }) => { + const organizationMenu = useFilterMenu({ + onChange: (option) => + filter.update({ ...filter.values, organization: option?.value }), + value: filter.values.organization, + id: "organization", + getSelectedOption: async () => { + if (!filter.values.organization) { + return null; + } + + const org = await API.getOrganization(filter.values.organization); + return orgOption(org); + }, + getOptions: async () => { + const orgs = await API.getMyOrganizations(); + return orgs.map(orgOption); + }, + }); + + return ( + + + + } + skeleton={ + <> + + + + } + /> + ); +}; + +const orgOption = (org: Organization): SelectFilterOption => ({ + label: org.display_name || org.name, + value: org.name, + startIcon: ( + + ), +}); diff --git a/site/src/pages/TemplatesPage/TemplatesPage.tsx b/site/src/pages/TemplatesPage/TemplatesPage.tsx index 0002198bc9acb..b03242a5528cd 100644 --- a/site/src/pages/TemplatesPage/TemplatesPage.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPage.tsx @@ -1,7 +1,9 @@ import type { FC } from "react"; import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; +import { useSearchParams } from "react-router-dom"; import { templateExamples, templates } from "api/queries/templates"; +import { useFilter } from "components/Filter/filter"; import { useAuthenticated } from "contexts/auth/RequireAuth"; import { useDashboard } from "modules/dashboard/useDashboard"; import { pageTitle } from "utils/page"; @@ -11,7 +13,14 @@ export const TemplatesPage: FC = () => { const { permissions } = useAuthenticated(); const { showOrganizations } = useDashboard(); - const templatesQuery = useQuery(templates()); + const searchParamsResult = useSearchParams(); + const filter = useFilter({ + fallbackFilter: "deprecated:false", + searchParamsResult, + onUpdate: () => {}, // reset pagination + }); + + const templatesQuery = useQuery(templates({ q: filter.query })); const examplesQuery = useQuery({ ...templateExamples(), enabled: permissions.createTemplates, @@ -25,6 +34,7 @@ export const TemplatesPage: FC = () => { = { decorators: [withDashboardProvider], parameters: { chromatic: chromaticWithTablet }, component: TemplatesPageView, + args: { + ...getDefaultFilterProps({ + query: "deprecated:false", + menus: {}, + values: {}, + }), + }, }; export default meta; diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index 9c0138ad08cdc..2a929db7549d0 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -17,6 +17,7 @@ import { ExternalAvatar } from "components/Avatar/Avatar"; import { AvatarData } from "components/AvatarData/AvatarData"; import { AvatarDataSkeleton } from "components/AvatarData/AvatarDataSkeleton"; import { DeprecatedBadge } from "components/Badges/Badges"; +import type { useFilter } from "components/Filter/filter"; import { HelpTooltip, HelpTooltipContent, @@ -46,6 +47,7 @@ import { formatTemplateActiveDevelopers, } from "utils/templates"; import { EmptyTemplates } from "./EmptyTemplates"; +import { TemplatesFilter } from "./TemplatesFilter"; export const Language = { developerCount: (activeCount: number): string => { @@ -173,6 +175,7 @@ const TemplateRow: FC = ({ showOrganizations, template }) => { export interface TemplatesPageViewProps { error?: unknown; + filter: ReturnType; showOrganizations: boolean; canCreateTemplates: boolean; examples: TemplateExample[] | undefined; @@ -181,6 +184,7 @@ export interface TemplatesPageViewProps { export const TemplatesPageView: FC = ({ error, + filter, showOrganizations, canCreateTemplates, examples, @@ -213,13 +217,13 @@ export const TemplatesPageView: FC = ({ - {templates && templates.length > 0 && ( - - Select a template to create a workspace. - - )} + + Select a template to create a workspace. + + + {error ? ( ) : ( diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 48f78e71f28e2..dd7fd89b192e6 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -6,7 +6,7 @@ import StopOutlined from "@mui/icons-material/StopOutlined"; import LoadingButton from "@mui/lab/LoadingButton"; import Button from "@mui/material/Button"; import Divider from "@mui/material/Divider"; -import type { ComponentProps } from "react"; +import type { ComponentProps, FC } from "react"; import type { UseQueryResult } from "react-query"; import { hasError, isApiValidationError } from "api/errors"; import type { Template, Workspace } from "api/typesGenerated"; @@ -65,7 +65,7 @@ export interface WorkspacesPageViewProps { canChangeVersions: boolean; } -export const WorkspacesPageView = ({ +export const WorkspacesPageView: FC = ({ workspaces, error, limit, @@ -86,7 +86,7 @@ export const WorkspacesPageView = ({ templatesFetchStatus, canCreateTemplate, canChangeVersions, -}: WorkspacesPageViewProps) => { +}) => { // Let's say the user has 5 workspaces, but tried to hit page 100, which does // not exist. In this case, the page is not valid and we want to show a better // error message. diff --git a/site/src/pages/WorkspacesPage/filter/menus.tsx b/site/src/pages/WorkspacesPage/filter/menus.tsx index 5502f05e082cb..a4d23cd70ad94 100644 --- a/site/src/pages/WorkspacesPage/filter/menus.tsx +++ b/site/src/pages/WorkspacesPage/filter/menus.tsx @@ -43,8 +43,7 @@ export const useTemplateFilterMenu = ({ template.display_name.toLowerCase().includes(query.toLowerCase()), ); return filteredTemplates.map((template) => ({ - label: - template.display_name !== "" ? template.display_name : template.name, + label: template.display_name || template.name, value: template.name, startIcon: , }));