From 95dccf34247738fb84ae09cd8fefcee190d8bd6d Mon Sep 17 00:00:00 2001 From: Rafael Rodriguez Date: Thu, 28 Aug 2025 13:59:28 -0500 Subject: [PATCH] feat: add user filter to templates page to filter by template author (#19561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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:` 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 Screenshot 2025-08-26 at 5 24 07 PM Admin View - Using the user filter https://github.com/user-attachments/assets/b4570cca-6dff-45c1-89ab-844f126bdc0f User view - Cannot view all users Screenshot 2025-08-26 at 5 25 38 PM ### Testing - Added storybook test for viewing the templates page with a user dropdown --- site/src/components/Filter/UserFilter.tsx | 2 + site/src/pages/AuditPage/AuditFilter.tsx | 9 ++- .../ConnectionLogPage/ConnectionLogFilter.tsx | 9 ++- .../pages/TemplatesPage/TemplatesFilter.tsx | 43 +++++++++++---- .../src/pages/TemplatesPage/TemplatesPage.tsx | 50 +++++++++++++++-- .../TemplatesPageView.stories.tsx | 55 +++++++++++++++---- .../pages/TemplatesPage/TemplatesPageView.tsx | 14 +++-- .../filter/WorkspacesFilter.tsx | 8 ++- 8 files changed, 149 insertions(+), 41 deletions(-) diff --git a/site/src/components/Filter/UserFilter.tsx b/site/src/components/Filter/UserFilter.tsx index 0663d3d8d9..5f0e680434 100644 --- a/site/src/components/Filter/UserFilter.tsx +++ b/site/src/components/Filter/UserFilter.tsx @@ -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, diff --git a/site/src/pages/AuditPage/AuditFilter.tsx b/site/src/pages/AuditPage/AuditFilter.tsx index 973d2d7a8e..49a40b4136 100644 --- a/site/src/pages/AuditPage/AuditFilter.tsx +++ b/site/src/pages/AuditPage/AuditFilter.tsx @@ -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 = ({ filter, error, menus }) => { - const width = menus.organization ? 175 : undefined; - + const width = menus.organization ? DEFAULT_USER_FILTER_WIDTH : undefined; return ( = ({ error, menus, }) => { - const width = menus.organization ? 175 : undefined; - + const width = menus.organization ? DEFAULT_USER_FILTER_WIDTH : undefined; return ( ; + filter: UseFilterResult; error?: unknown; + + userMenu?: UserFilterMenu; } export const TemplatesFilter: FC = ({ 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 = ({ filter={filter} error={error} options={ - + <> + {userMenu && } + + + } + optionsSkeleton={ + <> + {userMenu && } + + } - optionsSkeleton={} /> ); }; diff --git a/site/src/pages/TemplatesPage/TemplatesPage.tsx b/site/src/pages/TemplatesPage/TemplatesPage.tsx index d03d29716b..48132ab175 100644 --- a/site/src/pages/TemplatesPage/TemplatesPage.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPage.tsx @@ -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 = () => { { }; export default TemplatesPage; + +export type TemplateFilterState = { + filter: UseFilterResult; + menus: { + user?: ReturnType; + }; +}; + +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, + }, + }; +}; diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx index 9d8e55c171..58b0bdb9ff 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx @@ -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({ + query: "deprecated:false", + menus: { + organizations: MockMenu, + }, + values: { + author: MockUserOwner.username, + }, +}); + const meta: Meta = { 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" }, + }, + }, }, }; diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index a37cb31232..e36b278949 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -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 = ({ interface TemplatesPageViewProps { error?: unknown; - filter: ReturnType; + filterState: TemplateFilterState; showOrganizations: boolean; canCreateTemplates: boolean; examples: TemplateExample[] | undefined; @@ -194,7 +194,7 @@ interface TemplatesPageViewProps { export const TemplatesPageView: FC = ({ error, - filter, + filterState, showOrganizations, canCreateTemplates, examples, @@ -229,7 +229,11 @@ export const TemplatesPageView: FC = ({ - + {/* Validation errors are shown on the filter, other errors are an alert box. */} {hasError(error) && !isApiValidationError(error) && ( @@ -256,7 +260,7 @@ export const TemplatesPageView: FC = ({ ) : ( templates?.map((template) => ( diff --git a/site/src/pages/WorkspacesPage/filter/WorkspacesFilter.tsx b/site/src/pages/WorkspacesPage/filter/WorkspacesFilter.tsx index caebfd0452..8f45143ffa 100644 --- a/site/src/pages/WorkspacesPage/filter/WorkspacesFilter.tsx +++ b/site/src/pages/WorkspacesPage/filter/WorkspacesFilter.tsx @@ -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 = ({ 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;