Skip to content

Commit 8563b37

Browse files
authored
feat: filter templates by organization (coder#14254)
1 parent 4fc0479 commit 8563b37

File tree

12 files changed

+169
-45
lines changed

12 files changed

+169
-45
lines changed

coderd/database/queries.sql.go

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/templates.sql

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@ WHERE
2828
LOWER("name") = LOWER(@exact_name)
2929
ELSE true
3030
END
31-
-- Filter by name, matching on substring
32-
AND CASE
33-
WHEN @fuzzy_name :: text != '' THEN
34-
lower(name) ILIKE '%' || lower(@fuzzy_name) || '%'
35-
ELSE true
31+
-- Filter by name, matching on substring
32+
AND CASE
33+
WHEN @fuzzy_name :: text != '' THEN
34+
lower(name) ILIKE '%' || lower(@fuzzy_name) || '%'
35+
ELSE true
3636
END
3737
-- Filter by ids
3838
AND CASE

coderd/searchquery/search.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,9 +198,9 @@ func Templates(ctx context.Context, db database.Store, query string) (database.G
198198

199199
parser := httpapi.NewQueryParamParser()
200200
filter := database.GetTemplatesWithFilterParams{
201-
FuzzyName: parser.String(values, "", "name"),
202201
Deleted: parser.Boolean(values, false, "deleted"),
203202
ExactName: parser.String(values, "", "exact_name"),
203+
FuzzyName: parser.String(values, "", "name"),
204204
IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"),
205205
Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"),
206206
}

site/src/api/api.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,9 +304,17 @@ export type GetTemplatesOptions = Readonly<{
304304
readonly deprecated?: boolean;
305305
}>;
306306

307+
export type GetTemplatesQuery = Readonly<{
308+
readonly q: string;
309+
}>;
310+
307311
function normalizeGetTemplatesOptions(
308-
options: GetTemplatesOptions = {},
312+
options: GetTemplatesOptions | GetTemplatesQuery = {},
309313
): Record<string, string> {
314+
if ("q" in options) {
315+
return options;
316+
}
317+
310318
const params: Record<string, string> = {};
311319
if (options.deprecated !== undefined) {
312320
params["deprecated"] = String(options.deprecated);
@@ -666,6 +674,13 @@ class ApiMethods {
666674
return response.data;
667675
};
668676

677+
getMyOrganizations = async (): Promise<TypesGen.Organization[]> => {
678+
const response = await this.axios.get<TypesGen.Organization[]>(
679+
"/api/v2/users/me/organizations",
680+
);
681+
return response.data;
682+
};
683+
669684
/**
670685
* @param organization Can be the organization's ID or name
671686
*/
@@ -687,7 +702,7 @@ class ApiMethods {
687702
};
688703

689704
getTemplates = async (
690-
options?: GetTemplatesOptions,
705+
options?: GetTemplatesOptions | GetTemplatesQuery,
691706
): Promise<TypesGen.Template[]> => {
692707
const params = normalizeGetTemplatesOptions(options);
693708
const response = await this.axios.get<TypesGen.Template[]>(

site/src/api/queries/templates.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { MutationOptions, QueryClient, QueryOptions } from "react-query";
2-
import { API, type GetTemplatesOptions } from "api/api";
2+
import { API, type GetTemplatesQuery, type GetTemplatesOptions } from "api/api";
33
import type {
44
CreateTemplateRequest,
55
CreateTemplateVersionRequest,
@@ -38,12 +38,13 @@ export const templateByName = (
3838
};
3939
};
4040

41-
const getTemplatesQueryKey = (options?: GetTemplatesOptions) => [
42-
"templates",
43-
options?.deprecated,
44-
];
41+
const getTemplatesQueryKey = (
42+
options?: GetTemplatesOptions | GetTemplatesQuery,
43+
) => ["templates", options];
4544

46-
export const templates = (options?: GetTemplatesOptions) => {
45+
export const templates = (
46+
options?: GetTemplatesOptions | GetTemplatesQuery,
47+
) => {
4748
return {
4849
queryKey: getTemplatesQueryKey(options),
4950
queryFn: () => API.getTemplates(options),

site/src/components/Filter/filter.tsx

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ type FilterProps = {
137137
filter: ReturnType<typeof useFilter>;
138138
skeleton: ReactNode;
139139
isLoading: boolean;
140-
learnMoreLink: string;
140+
learnMoreLink?: string;
141141
learnMoreLabel2?: string;
142142
learnMoreLink2?: string;
143143
error?: unknown;
@@ -240,7 +240,7 @@ export const Filter: FC<FilterProps> = ({
240240

241241
interface PresetMenuProps {
242242
presets: PresetFilter[];
243-
learnMoreLink: string;
243+
learnMoreLink?: string;
244244
learnMoreLabel2?: string;
245245
learnMoreLink2?: string;
246246
onSelect: (query: string) => void;
@@ -293,19 +293,23 @@ const PresetMenu: FC<PresetMenuProps> = ({
293293
{presetFilter.name}
294294
</MenuItem>
295295
))}
296-
<Divider css={{ borderColor: theme.palette.divider }} />
297-
<MenuItem
298-
component="a"
299-
href={learnMoreLink}
300-
target="_blank"
301-
css={{ fontSize: 13, fontWeight: 500 }}
302-
onClick={() => {
303-
setIsOpen(false);
304-
}}
305-
>
306-
<OpenInNewOutlined css={{ fontSize: "14px !important" }} />
307-
View advanced filtering
308-
</MenuItem>
296+
{learnMoreLink && (
297+
<>
298+
<Divider css={{ borderColor: theme.palette.divider }} />
299+
<MenuItem
300+
component="a"
301+
href={learnMoreLink}
302+
target="_blank"
303+
css={{ fontSize: 13, fontWeight: 500 }}
304+
onClick={() => {
305+
setIsOpen(false);
306+
}}
307+
>
308+
<OpenInNewOutlined css={{ fontSize: "14px !important" }} />
309+
View advanced filtering
310+
</MenuItem>
311+
</>
312+
)}
309313
{learnMoreLink2 && learnMoreLabel2 && (
310314
<MenuItem
311315
component="a"
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import type { FC } from "react";
2+
import { API } from "api/api";
3+
import type { Organization } from "api/typesGenerated";
4+
import {
5+
Filter,
6+
MenuSkeleton,
7+
SearchFieldSkeleton,
8+
type useFilter,
9+
} from "components/Filter/filter";
10+
import { useFilterMenu } from "components/Filter/menu";
11+
import {
12+
SelectFilter,
13+
type SelectFilterOption,
14+
} from "components/Filter/SelectFilter";
15+
import { UserAvatar } from "components/UserAvatar/UserAvatar";
16+
17+
interface TemplatesFilterProps {
18+
filter: ReturnType<typeof useFilter>;
19+
}
20+
21+
export const TemplatesFilter: FC<TemplatesFilterProps> = ({ filter }) => {
22+
const organizationMenu = useFilterMenu({
23+
onChange: (option) =>
24+
filter.update({ ...filter.values, organization: option?.value }),
25+
value: filter.values.organization,
26+
id: "organization",
27+
getSelectedOption: async () => {
28+
if (!filter.values.organization) {
29+
return null;
30+
}
31+
32+
const org = await API.getOrganization(filter.values.organization);
33+
return orgOption(org);
34+
},
35+
getOptions: async () => {
36+
const orgs = await API.getMyOrganizations();
37+
return orgs.map(orgOption);
38+
},
39+
});
40+
41+
return (
42+
<Filter
43+
presets={[
44+
{ query: "", name: "All templates" },
45+
{ query: "deprecated:true", name: "Deprecated templates" },
46+
]}
47+
// TODO: Add docs for this
48+
// learnMoreLink={docs("/templates#template-filtering")}
49+
isLoading={false}
50+
filter={filter}
51+
options={
52+
<>
53+
<SelectFilter
54+
placeholder="All organizations"
55+
label="Select an organization"
56+
options={organizationMenu.searchOptions}
57+
selectedOption={organizationMenu.selectedOption ?? undefined}
58+
onSelect={organizationMenu.selectOption}
59+
/>
60+
</>
61+
}
62+
skeleton={
63+
<>
64+
<SearchFieldSkeleton />
65+
<MenuSkeleton />
66+
</>
67+
}
68+
/>
69+
);
70+
};
71+
72+
const orgOption = (org: Organization): SelectFilterOption => ({
73+
label: org.display_name || org.name,
74+
value: org.name,
75+
startIcon: (
76+
<UserAvatar
77+
key={org.id}
78+
size="xs"
79+
username={org.display_name}
80+
avatarURL={org.icon}
81+
/>
82+
),
83+
});

site/src/pages/TemplatesPage/TemplatesPage.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import type { FC } from "react";
22
import { Helmet } from "react-helmet-async";
33
import { useQuery } from "react-query";
4+
import { useSearchParams } from "react-router-dom";
45
import { templateExamples, templates } from "api/queries/templates";
6+
import { useFilter } from "components/Filter/filter";
57
import { useAuthenticated } from "contexts/auth/RequireAuth";
68
import { useDashboard } from "modules/dashboard/useDashboard";
79
import { pageTitle } from "utils/page";
@@ -11,7 +13,14 @@ export const TemplatesPage: FC = () => {
1113
const { permissions } = useAuthenticated();
1214
const { showOrganizations } = useDashboard();
1315

14-
const templatesQuery = useQuery(templates());
16+
const searchParamsResult = useSearchParams();
17+
const filter = useFilter({
18+
fallbackFilter: "deprecated:false",
19+
searchParamsResult,
20+
onUpdate: () => {}, // reset pagination
21+
});
22+
23+
const templatesQuery = useQuery(templates({ q: filter.query }));
1524
const examplesQuery = useQuery({
1625
...templateExamples(),
1726
enabled: permissions.createTemplates,
@@ -25,6 +34,7 @@ export const TemplatesPage: FC = () => {
2534
</Helmet>
2635
<TemplatesPageView
2736
error={error}
37+
filter={filter}
2838
showOrganizations={showOrganizations}
2939
canCreateTemplates={permissions.createTemplates}
3040
examples={examplesQuery.data}

site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Meta, StoryObj } from "@storybook/react";
2+
import { getDefaultFilterProps } from "components/Filter/storyHelpers";
23
import { chromaticWithTablet } from "testHelpers/chromatic";
34
import {
45
mockApiError,
@@ -14,6 +15,13 @@ const meta: Meta<typeof TemplatesPageView> = {
1415
decorators: [withDashboardProvider],
1516
parameters: { chromatic: chromaticWithTablet },
1617
component: TemplatesPageView,
18+
args: {
19+
...getDefaultFilterProps({
20+
query: "deprecated:false",
21+
menus: {},
22+
values: {},
23+
}),
24+
},
1725
};
1826

1927
export default meta;

site/src/pages/TemplatesPage/TemplatesPageView.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { ExternalAvatar } from "components/Avatar/Avatar";
1717
import { AvatarData } from "components/AvatarData/AvatarData";
1818
import { AvatarDataSkeleton } from "components/AvatarData/AvatarDataSkeleton";
1919
import { DeprecatedBadge } from "components/Badges/Badges";
20+
import type { useFilter } from "components/Filter/filter";
2021
import {
2122
HelpTooltip,
2223
HelpTooltipContent,
@@ -46,6 +47,7 @@ import {
4647
formatTemplateActiveDevelopers,
4748
} from "utils/templates";
4849
import { EmptyTemplates } from "./EmptyTemplates";
50+
import { TemplatesFilter } from "./TemplatesFilter";
4951

5052
export const Language = {
5153
developerCount: (activeCount: number): string => {
@@ -173,6 +175,7 @@ const TemplateRow: FC<TemplateRowProps> = ({ showOrganizations, template }) => {
173175

174176
export interface TemplatesPageViewProps {
175177
error?: unknown;
178+
filter: ReturnType<typeof useFilter>;
176179
showOrganizations: boolean;
177180
canCreateTemplates: boolean;
178181
examples: TemplateExample[] | undefined;
@@ -181,6 +184,7 @@ export interface TemplatesPageViewProps {
181184

182185
export const TemplatesPageView: FC<TemplatesPageViewProps> = ({
183186
error,
187+
filter,
184188
showOrganizations,
185189
canCreateTemplates,
186190
examples,
@@ -213,13 +217,13 @@ export const TemplatesPageView: FC<TemplatesPageViewProps> = ({
213217
<TemplateHelpTooltip />
214218
</Stack>
215219
</PageHeaderTitle>
216-
{templates && templates.length > 0 && (
217-
<PageHeaderSubtitle>
218-
Select a template to create a workspace.
219-
</PageHeaderSubtitle>
220-
)}
220+
<PageHeaderSubtitle>
221+
Select a template to create a workspace.
222+
</PageHeaderSubtitle>
221223
</PageHeader>
222224

225+
<TemplatesFilter filter={filter} />
226+
223227
{error ? (
224228
<ErrorAlert error={error} />
225229
) : (

site/src/pages/WorkspacesPage/WorkspacesPageView.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import StopOutlined from "@mui/icons-material/StopOutlined";
66
import LoadingButton from "@mui/lab/LoadingButton";
77
import Button from "@mui/material/Button";
88
import Divider from "@mui/material/Divider";
9-
import type { ComponentProps } from "react";
9+
import type { ComponentProps, FC } from "react";
1010
import type { UseQueryResult } from "react-query";
1111
import { hasError, isApiValidationError } from "api/errors";
1212
import type { Template, Workspace } from "api/typesGenerated";
@@ -65,7 +65,7 @@ export interface WorkspacesPageViewProps {
6565
canChangeVersions: boolean;
6666
}
6767

68-
export const WorkspacesPageView = ({
68+
export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({
6969
workspaces,
7070
error,
7171
limit,
@@ -86,7 +86,7 @@ export const WorkspacesPageView = ({
8686
templatesFetchStatus,
8787
canCreateTemplate,
8888
canChangeVersions,
89-
}: WorkspacesPageViewProps) => {
89+
}) => {
9090
// Let's say the user has 5 workspaces, but tried to hit page 100, which does
9191
// not exist. In this case, the page is not valid and we want to show a better
9292
// error message.

site/src/pages/WorkspacesPage/filter/menus.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,7 @@ export const useTemplateFilterMenu = ({
4343
template.display_name.toLowerCase().includes(query.toLowerCase()),
4444
);
4545
return filteredTemplates.map((template) => ({
46-
label:
47-
template.display_name !== "" ? template.display_name : template.name,
46+
label: template.display_name || template.name,
4847
value: template.name,
4948
startIcon: <TemplateAvatar size="xs" template={template} />,
5049
}));

0 commit comments

Comments
 (0)