feat: add user filter to templates page to filter by template author (#19561)

## Summary

In this pull request we're adding a user selector dropdown to the
templates page that allows an admin to select a user. The selected user
will be used in the `author:<username>` filter to filter the templates
list by a template author.

Closes: https://github.com/coder/coder/issues/19547

### Changes

Admin View - Can view all users

<img width="1622" height="489" alt="Screenshot 2025-08-26 at 5 24 07 PM"
src="https://github.com/user-attachments/assets/f2ace51e-5834-4bed-bd4f-14c6800816f0"
/>

Admin View - Using the user filter


https://github.com/user-attachments/assets/b4570cca-6dff-45c1-89ab-844f126bdc0f

User view - Cannot view all users

<img width="1617" height="455" alt="Screenshot 2025-08-26 at 5 25 38 PM"
src="https://github.com/user-attachments/assets/f8680acb-d463-4a22-826e-053f0e7dbe21"
/>

### Testing

- Added storybook test for viewing the templates page with a user
dropdown
This commit is contained in:
Rafael Rodriguez
2025-08-28 13:59:28 -05:00
committed by GitHub
parent 75b38f12d8
commit 95dccf3424
8 changed files with 149 additions and 41 deletions
@@ -9,6 +9,8 @@ import { useAuthenticated } from "hooks";
import type { FC } from "react";
import { type UseFilterMenuOptions, useFilterMenu } from "./menu";
export const DEFAULT_USER_FILTER_WIDTH = 175;
export const useUserFilterMenu = ({
value,
onChange,
+6 -3
View File
@@ -8,7 +8,11 @@ import {
SelectFilter,
type SelectFilterOption,
} from "components/Filter/SelectFilter";
import { type UserFilterMenu, UserMenu } from "components/Filter/UserFilter";
import {
DEFAULT_USER_FILTER_WIDTH,
type UserFilterMenu,
UserMenu,
} from "components/Filter/UserFilter";
import capitalize from "lodash/capitalize";
import {
type OrganizationsFilterMenu,
@@ -47,8 +51,7 @@ interface AuditFilterProps {
}
export const AuditFilter: FC<AuditFilterProps> = ({ filter, error, menus }) => {
const width = menus.organization ? 175 : undefined;
const width = menus.organization ? DEFAULT_USER_FILTER_WIDTH : undefined;
return (
<Filter
learnMoreLink={docs("/admin/security/audit-logs#filtering-logs")}
@@ -8,7 +8,11 @@ import {
SelectFilter,
type SelectFilterOption,
} from "components/Filter/SelectFilter";
import { type UserFilterMenu, UserMenu } from "components/Filter/UserFilter";
import {
DEFAULT_USER_FILTER_WIDTH,
type UserFilterMenu,
UserMenu,
} from "components/Filter/UserFilter";
import capitalize from "lodash/capitalize";
import {
type OrganizationsFilterMenu,
@@ -42,8 +46,7 @@ export const ConnectionLogFilter: FC<ConnectionLogFilterProps> = ({
error,
menus,
}) => {
const width = menus.organization ? 175 : undefined;
const width = menus.organization ? DEFAULT_USER_FILTER_WIDTH : undefined;
return (
<Filter
learnMoreLink={docs(
@@ -1,23 +1,38 @@
import { API } from "api/api";
import type { Organization } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { Filter, MenuSkeleton, type useFilter } from "components/Filter/Filter";
import {
Filter,
MenuSkeleton,
type UseFilterResult,
} from "components/Filter/Filter";
import { useFilterMenu } from "components/Filter/menu";
import {
SelectFilter,
type SelectFilterOption,
} from "components/Filter/SelectFilter";
import { useDashboard } from "modules/dashboard/useDashboard";
import type { FC } from "react";
import {
DEFAULT_USER_FILTER_WIDTH,
type UserFilterMenu,
UserMenu,
} from "../../components/Filter/UserFilter";
interface TemplatesFilterProps {
filter: ReturnType<typeof useFilter>;
filter: UseFilterResult;
error?: unknown;
userMenu?: UserFilterMenu;
}
export const TemplatesFilter: FC<TemplatesFilterProps> = ({
filter,
error,
userMenu,
}) => {
const { showOrganizations } = useDashboard();
const width = showOrganizations ? DEFAULT_USER_FILTER_WIDTH : undefined;
const organizationMenu = useFilterMenu({
onChange: (option) =>
filter.update({ ...filter.values, organization: option?.value }),
@@ -50,15 +65,23 @@ export const TemplatesFilter: FC<TemplatesFilterProps> = ({
filter={filter}
error={error}
options={
<SelectFilter
placeholder="All organizations"
label="Select an organization"
options={organizationMenu.searchOptions}
selectedOption={organizationMenu.selectedOption ?? undefined}
onSelect={organizationMenu.selectOption}
/>
<>
{userMenu && <UserMenu width={width} menu={userMenu} />}
<SelectFilter
placeholder="All organizations"
label="Select an organization"
options={organizationMenu.searchOptions}
selectedOption={organizationMenu.selectedOption ?? undefined}
onSelect={organizationMenu.selectOption}
/>
</>
}
optionsSkeleton={
<>
{userMenu && <MenuSkeleton />}
<MenuSkeleton />
</>
}
optionsSkeleton={<MenuSkeleton />}
/>
);
};
+44 -6
View File
@@ -1,6 +1,7 @@
import { workspacePermissionsByOrganization } from "api/queries/organizations";
import { templateExamples, templates } from "api/queries/templates";
import { useFilter } from "components/Filter/Filter";
import { type UseFilterResult, useFilter } from "components/Filter/Filter";
import { useUserFilterMenu } from "components/Filter/UserFilter";
import { useAuthenticated } from "hooks";
import { useDashboard } from "modules/dashboard/useDashboard";
import type { FC } from "react";
@@ -15,14 +16,12 @@ const TemplatesPage: FC = () => {
const { showOrganizations } = useDashboard();
const [searchParams, setSearchParams] = useSearchParams();
const filter = useFilter({
fallbackFilter: "deprecated:false",
const filterState = useTemplatesFilter({
searchParams,
onSearchParamsChange: setSearchParams,
onUpdate: () => {}, // reset pagination
});
const templatesQuery = useQuery(templates({ q: filter.query }));
const templatesQuery = useQuery(templates({ q: filterState.filter.query }));
const examplesQuery = useQuery({
...templateExamples(),
enabled: permissions.createTemplates,
@@ -47,7 +46,7 @@ const TemplatesPage: FC = () => {
</Helmet>
<TemplatesPageView
error={error}
filter={filter}
filterState={filterState}
showOrganizations={showOrganizations}
canCreateTemplates={permissions.createTemplates}
examples={examplesQuery.data}
@@ -59,3 +58,42 @@ const TemplatesPage: FC = () => {
};
export default TemplatesPage;
export type TemplateFilterState = {
filter: UseFilterResult;
menus: {
user?: ReturnType<typeof useUserFilterMenu>;
};
};
type UseTemplatesFilterOptions = {
searchParams: URLSearchParams;
onSearchParamsChange: (params: URLSearchParams) => void;
};
const useTemplatesFilter = ({
searchParams,
onSearchParamsChange,
}: UseTemplatesFilterOptions): TemplateFilterState => {
const filter = useFilter({
fallbackFilter: "deprecated:false",
searchParams,
onSearchParamsChange,
});
const { permissions } = useAuthenticated();
const canFilterByUser = permissions.viewAllUsers;
const userMenu = useUserFilterMenu({
value: filter.values.author,
onChange: (option) =>
filter.update({ ...filter.values, author: option?.value }),
enabled: canFilterByUser,
});
return {
filter,
menus: {
user: canFilterByUser ? userMenu : undefined,
},
};
};
@@ -3,24 +3,35 @@ import {
MockTemplate,
MockTemplateExample,
MockTemplateExample2,
MockUserOwner,
mockApiError,
} from "testHelpers/entities";
import { withDashboardProvider } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { getDefaultFilterProps } from "components/Filter/storyHelpers";
import {
getDefaultFilterProps,
MockMenu,
} from "components/Filter/storyHelpers";
import type { TemplateFilterState } from "./TemplatesPage";
import { TemplatesPageView } from "./TemplatesPageView";
const defaultFilterProps = getDefaultFilterProps<TemplateFilterState>({
query: "deprecated:false",
menus: {
organizations: MockMenu,
},
values: {
author: MockUserOwner.username,
},
});
const meta: Meta<typeof TemplatesPageView> = {
title: "pages/TemplatesPage",
decorators: [withDashboardProvider],
parameters: { chromatic: chromaticWithTablet },
component: TemplatesPageView,
args: {
...getDefaultFilterProps({
query: "deprecated:false",
menus: {},
values: {},
}),
filterState: defaultFilterProps,
},
};
@@ -104,12 +115,32 @@ export const WithFilteredAllTemplates: Story = {
args: {
...WithTemplates.args,
templates: [],
...getDefaultFilterProps({
query: "deprecated:false searchnotfound",
menus: {},
values: {},
used: true,
}),
filterState: {
filter: {
...defaultFilterProps.filter,
query: "deprecated:false searchnotfound",
values: {},
used: true,
},
menus: defaultFilterProps.menus,
},
},
};
export const WithUserDropdown: Story = {
args: {
...WithTemplates.args,
filterState: {
...defaultFilterProps,
menus: {
user: MockMenu,
},
filter: {
...defaultFilterProps.filter,
query: "author:me",
values: { author: "me" },
},
},
},
};
@@ -9,7 +9,6 @@ import { AvatarData } from "components/Avatar/AvatarData";
import { AvatarDataSkeleton } from "components/Avatar/AvatarDataSkeleton";
import { DeprecatedBadge } from "components/Badges/Badges";
import { Button } from "components/Button/Button";
import type { useFilter } from "components/Filter/Filter";
import {
HelpTooltip,
HelpTooltipContent,
@@ -52,6 +51,7 @@ import {
} from "utils/templates";
import { EmptyTemplates } from "./EmptyTemplates";
import { TemplatesFilter } from "./TemplatesFilter";
import type { TemplateFilterState } from "./TemplatesPage";
const Language = {
developerCount: (activeCount: number): string => {
@@ -184,7 +184,7 @@ const TemplateRow: FC<TemplateRowProps> = ({
interface TemplatesPageViewProps {
error?: unknown;
filter: ReturnType<typeof useFilter>;
filterState: TemplateFilterState;
showOrganizations: boolean;
canCreateTemplates: boolean;
examples: TemplateExample[] | undefined;
@@ -194,7 +194,7 @@ interface TemplatesPageViewProps {
export const TemplatesPageView: FC<TemplatesPageViewProps> = ({
error,
filter,
filterState,
showOrganizations,
canCreateTemplates,
examples,
@@ -229,7 +229,11 @@ export const TemplatesPageView: FC<TemplatesPageViewProps> = ({
</PageHeaderSubtitle>
</PageHeader>
<TemplatesFilter filter={filter} error={error} />
<TemplatesFilter
filter={filterState.filter}
error={error}
userMenu={filterState.menus.user}
/>
{/* Validation errors are shown on the filter, other errors are an alert box. */}
{hasError(error) && !isApiValidationError(error) && (
<ErrorAlert error={error} />
@@ -256,7 +260,7 @@ export const TemplatesPageView: FC<TemplatesPageViewProps> = ({
<EmptyTemplates
canCreateTemplates={canCreateTemplates}
examples={examples ?? []}
isUsingFilter={filter.used}
isUsingFilter={filterState.filter.used}
/>
) : (
templates?.map((template) => (
@@ -3,7 +3,11 @@ import {
MenuSkeleton,
type UseFilterResult,
} from "components/Filter/Filter";
import { type UserFilterMenu, UserMenu } from "components/Filter/UserFilter";
import {
DEFAULT_USER_FILTER_WIDTH,
type UserFilterMenu,
UserMenu,
} from "components/Filter/UserFilter";
import { useDashboard } from "modules/dashboard/useDashboard";
import {
type OrganizationsFilterMenu,
@@ -96,7 +100,7 @@ export const WorkspacesFilter: FC<WorkspaceFilterProps> = ({
organizationsMenu,
}) => {
const { entitlements, showOrganizations } = useDashboard();
const width = showOrganizations ? 175 : undefined;
const width = showOrganizations ? DEFAULT_USER_FILTER_WIDTH : undefined;
const presets = entitlements.features.advanced_template_scheduling.enabled
? PRESETS_WITH_DORMANT
: PRESET_FILTERS;