mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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 />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user