mirror of
https://github.com/coder/coder.git
synced 2025-07-18 14:17:22 +00:00
feat: filter templates by organization (#14254)
This commit is contained in:
committed by
GitHub
parent
4fc047954e
commit
8563b372e8
@ -198,9 +198,9 @@ func Templates(ctx context.Context, db database.Store, query string) (database.G
|
|||||||
|
|
||||||
parser := httpapi.NewQueryParamParser()
|
parser := httpapi.NewQueryParamParser()
|
||||||
filter := database.GetTemplatesWithFilterParams{
|
filter := database.GetTemplatesWithFilterParams{
|
||||||
FuzzyName: parser.String(values, "", "name"),
|
|
||||||
Deleted: parser.Boolean(values, false, "deleted"),
|
Deleted: parser.Boolean(values, false, "deleted"),
|
||||||
ExactName: parser.String(values, "", "exact_name"),
|
ExactName: parser.String(values, "", "exact_name"),
|
||||||
|
FuzzyName: parser.String(values, "", "name"),
|
||||||
IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"),
|
IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"),
|
||||||
Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"),
|
Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"),
|
||||||
}
|
}
|
||||||
|
@ -304,9 +304,17 @@ export type GetTemplatesOptions = Readonly<{
|
|||||||
readonly deprecated?: boolean;
|
readonly deprecated?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
export type GetTemplatesQuery = Readonly<{
|
||||||
|
readonly q: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
function normalizeGetTemplatesOptions(
|
function normalizeGetTemplatesOptions(
|
||||||
options: GetTemplatesOptions = {},
|
options: GetTemplatesOptions | GetTemplatesQuery = {},
|
||||||
): Record<string, string> {
|
): Record<string, string> {
|
||||||
|
if ("q" in options) {
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
if (options.deprecated !== undefined) {
|
if (options.deprecated !== undefined) {
|
||||||
params["deprecated"] = String(options.deprecated);
|
params["deprecated"] = String(options.deprecated);
|
||||||
@ -666,6 +674,13 @@ class ApiMethods {
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
getMyOrganizations = async (): Promise<TypesGen.Organization[]> => {
|
||||||
|
const response = await this.axios.get<TypesGen.Organization[]>(
|
||||||
|
"/api/v2/users/me/organizations",
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param organization Can be the organization's ID or name
|
* @param organization Can be the organization's ID or name
|
||||||
*/
|
*/
|
||||||
@ -687,7 +702,7 @@ class ApiMethods {
|
|||||||
};
|
};
|
||||||
|
|
||||||
getTemplates = async (
|
getTemplates = async (
|
||||||
options?: GetTemplatesOptions,
|
options?: GetTemplatesOptions | GetTemplatesQuery,
|
||||||
): Promise<TypesGen.Template[]> => {
|
): Promise<TypesGen.Template[]> => {
|
||||||
const params = normalizeGetTemplatesOptions(options);
|
const params = normalizeGetTemplatesOptions(options);
|
||||||
const response = await this.axios.get<TypesGen.Template[]>(
|
const response = await this.axios.get<TypesGen.Template[]>(
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import type { MutationOptions, QueryClient, QueryOptions } from "react-query";
|
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 {
|
import type {
|
||||||
CreateTemplateRequest,
|
CreateTemplateRequest,
|
||||||
CreateTemplateVersionRequest,
|
CreateTemplateVersionRequest,
|
||||||
@ -38,12 +38,13 @@ export const templateByName = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTemplatesQueryKey = (options?: GetTemplatesOptions) => [
|
const getTemplatesQueryKey = (
|
||||||
"templates",
|
options?: GetTemplatesOptions | GetTemplatesQuery,
|
||||||
options?.deprecated,
|
) => ["templates", options];
|
||||||
];
|
|
||||||
|
|
||||||
export const templates = (options?: GetTemplatesOptions) => {
|
export const templates = (
|
||||||
|
options?: GetTemplatesOptions | GetTemplatesQuery,
|
||||||
|
) => {
|
||||||
return {
|
return {
|
||||||
queryKey: getTemplatesQueryKey(options),
|
queryKey: getTemplatesQueryKey(options),
|
||||||
queryFn: () => API.getTemplates(options),
|
queryFn: () => API.getTemplates(options),
|
||||||
|
@ -137,7 +137,7 @@ type FilterProps = {
|
|||||||
filter: ReturnType<typeof useFilter>;
|
filter: ReturnType<typeof useFilter>;
|
||||||
skeleton: ReactNode;
|
skeleton: ReactNode;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
learnMoreLink: string;
|
learnMoreLink?: string;
|
||||||
learnMoreLabel2?: string;
|
learnMoreLabel2?: string;
|
||||||
learnMoreLink2?: string;
|
learnMoreLink2?: string;
|
||||||
error?: unknown;
|
error?: unknown;
|
||||||
@ -240,7 +240,7 @@ export const Filter: FC<FilterProps> = ({
|
|||||||
|
|
||||||
interface PresetMenuProps {
|
interface PresetMenuProps {
|
||||||
presets: PresetFilter[];
|
presets: PresetFilter[];
|
||||||
learnMoreLink: string;
|
learnMoreLink?: string;
|
||||||
learnMoreLabel2?: string;
|
learnMoreLabel2?: string;
|
||||||
learnMoreLink2?: string;
|
learnMoreLink2?: string;
|
||||||
onSelect: (query: string) => void;
|
onSelect: (query: string) => void;
|
||||||
@ -293,6 +293,8 @@ const PresetMenu: FC<PresetMenuProps> = ({
|
|||||||
{presetFilter.name}
|
{presetFilter.name}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
|
{learnMoreLink && (
|
||||||
|
<>
|
||||||
<Divider css={{ borderColor: theme.palette.divider }} />
|
<Divider css={{ borderColor: theme.palette.divider }} />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
component="a"
|
component="a"
|
||||||
@ -306,6 +308,8 @@ const PresetMenu: FC<PresetMenuProps> = ({
|
|||||||
<OpenInNewOutlined css={{ fontSize: "14px !important" }} />
|
<OpenInNewOutlined css={{ fontSize: "14px !important" }} />
|
||||||
View advanced filtering
|
View advanced filtering
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{learnMoreLink2 && learnMoreLabel2 && (
|
{learnMoreLink2 && learnMoreLabel2 && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
component="a"
|
component="a"
|
||||||
|
83
site/src/pages/TemplatesPage/TemplatesFilter.tsx
Normal file
83
site/src/pages/TemplatesPage/TemplatesFilter.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
import { API } from "api/api";
|
||||||
|
import type { Organization } from "api/typesGenerated";
|
||||||
|
import {
|
||||||
|
Filter,
|
||||||
|
MenuSkeleton,
|
||||||
|
SearchFieldSkeleton,
|
||||||
|
type useFilter,
|
||||||
|
} from "components/Filter/filter";
|
||||||
|
import { useFilterMenu } from "components/Filter/menu";
|
||||||
|
import {
|
||||||
|
SelectFilter,
|
||||||
|
type SelectFilterOption,
|
||||||
|
} from "components/Filter/SelectFilter";
|
||||||
|
import { UserAvatar } from "components/UserAvatar/UserAvatar";
|
||||||
|
|
||||||
|
interface TemplatesFilterProps {
|
||||||
|
filter: ReturnType<typeof useFilter>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TemplatesFilter: FC<TemplatesFilterProps> = ({ 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 (
|
||||||
|
<Filter
|
||||||
|
presets={[
|
||||||
|
{ query: "", name: "All templates" },
|
||||||
|
{ query: "deprecated:true", name: "Deprecated templates" },
|
||||||
|
]}
|
||||||
|
// TODO: Add docs for this
|
||||||
|
// learnMoreLink={docs("/templates#template-filtering")}
|
||||||
|
isLoading={false}
|
||||||
|
filter={filter}
|
||||||
|
options={
|
||||||
|
<>
|
||||||
|
<SelectFilter
|
||||||
|
placeholder="All organizations"
|
||||||
|
label="Select an organization"
|
||||||
|
options={organizationMenu.searchOptions}
|
||||||
|
selectedOption={organizationMenu.selectedOption ?? undefined}
|
||||||
|
onSelect={organizationMenu.selectOption}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
skeleton={
|
||||||
|
<>
|
||||||
|
<SearchFieldSkeleton />
|
||||||
|
<MenuSkeleton />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const orgOption = (org: Organization): SelectFilterOption => ({
|
||||||
|
label: org.display_name || org.name,
|
||||||
|
value: org.name,
|
||||||
|
startIcon: (
|
||||||
|
<UserAvatar
|
||||||
|
key={org.id}
|
||||||
|
size="xs"
|
||||||
|
username={org.display_name}
|
||||||
|
avatarURL={org.icon}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
@ -1,7 +1,9 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
import { useQuery } from "react-query";
|
import { useQuery } from "react-query";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { templateExamples, templates } from "api/queries/templates";
|
import { templateExamples, templates } from "api/queries/templates";
|
||||||
|
import { useFilter } from "components/Filter/filter";
|
||||||
import { useAuthenticated } from "contexts/auth/RequireAuth";
|
import { useAuthenticated } from "contexts/auth/RequireAuth";
|
||||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||||
import { pageTitle } from "utils/page";
|
import { pageTitle } from "utils/page";
|
||||||
@ -11,7 +13,14 @@ export const TemplatesPage: FC = () => {
|
|||||||
const { permissions } = useAuthenticated();
|
const { permissions } = useAuthenticated();
|
||||||
const { showOrganizations } = useDashboard();
|
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({
|
const examplesQuery = useQuery({
|
||||||
...templateExamples(),
|
...templateExamples(),
|
||||||
enabled: permissions.createTemplates,
|
enabled: permissions.createTemplates,
|
||||||
@ -25,6 +34,7 @@ export const TemplatesPage: FC = () => {
|
|||||||
</Helmet>
|
</Helmet>
|
||||||
<TemplatesPageView
|
<TemplatesPageView
|
||||||
error={error}
|
error={error}
|
||||||
|
filter={filter}
|
||||||
showOrganizations={showOrganizations}
|
showOrganizations={showOrganizations}
|
||||||
canCreateTemplates={permissions.createTemplates}
|
canCreateTemplates={permissions.createTemplates}
|
||||||
examples={examplesQuery.data}
|
examples={examplesQuery.data}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import type { Meta, StoryObj } from "@storybook/react";
|
import type { Meta, StoryObj } from "@storybook/react";
|
||||||
|
import { getDefaultFilterProps } from "components/Filter/storyHelpers";
|
||||||
import { chromaticWithTablet } from "testHelpers/chromatic";
|
import { chromaticWithTablet } from "testHelpers/chromatic";
|
||||||
import {
|
import {
|
||||||
mockApiError,
|
mockApiError,
|
||||||
@ -14,6 +15,13 @@ const meta: Meta<typeof TemplatesPageView> = {
|
|||||||
decorators: [withDashboardProvider],
|
decorators: [withDashboardProvider],
|
||||||
parameters: { chromatic: chromaticWithTablet },
|
parameters: { chromatic: chromaticWithTablet },
|
||||||
component: TemplatesPageView,
|
component: TemplatesPageView,
|
||||||
|
args: {
|
||||||
|
...getDefaultFilterProps({
|
||||||
|
query: "deprecated:false",
|
||||||
|
menus: {},
|
||||||
|
values: {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
|
@ -17,6 +17,7 @@ import { ExternalAvatar } from "components/Avatar/Avatar";
|
|||||||
import { AvatarData } from "components/AvatarData/AvatarData";
|
import { AvatarData } from "components/AvatarData/AvatarData";
|
||||||
import { AvatarDataSkeleton } from "components/AvatarData/AvatarDataSkeleton";
|
import { AvatarDataSkeleton } from "components/AvatarData/AvatarDataSkeleton";
|
||||||
import { DeprecatedBadge } from "components/Badges/Badges";
|
import { DeprecatedBadge } from "components/Badges/Badges";
|
||||||
|
import type { useFilter } from "components/Filter/filter";
|
||||||
import {
|
import {
|
||||||
HelpTooltip,
|
HelpTooltip,
|
||||||
HelpTooltipContent,
|
HelpTooltipContent,
|
||||||
@ -46,6 +47,7 @@ import {
|
|||||||
formatTemplateActiveDevelopers,
|
formatTemplateActiveDevelopers,
|
||||||
} from "utils/templates";
|
} from "utils/templates";
|
||||||
import { EmptyTemplates } from "./EmptyTemplates";
|
import { EmptyTemplates } from "./EmptyTemplates";
|
||||||
|
import { TemplatesFilter } from "./TemplatesFilter";
|
||||||
|
|
||||||
export const Language = {
|
export const Language = {
|
||||||
developerCount: (activeCount: number): string => {
|
developerCount: (activeCount: number): string => {
|
||||||
@ -173,6 +175,7 @@ const TemplateRow: FC<TemplateRowProps> = ({ showOrganizations, template }) => {
|
|||||||
|
|
||||||
export interface TemplatesPageViewProps {
|
export interface TemplatesPageViewProps {
|
||||||
error?: unknown;
|
error?: unknown;
|
||||||
|
filter: ReturnType<typeof useFilter>;
|
||||||
showOrganizations: boolean;
|
showOrganizations: boolean;
|
||||||
canCreateTemplates: boolean;
|
canCreateTemplates: boolean;
|
||||||
examples: TemplateExample[] | undefined;
|
examples: TemplateExample[] | undefined;
|
||||||
@ -181,6 +184,7 @@ export interface TemplatesPageViewProps {
|
|||||||
|
|
||||||
export const TemplatesPageView: FC<TemplatesPageViewProps> = ({
|
export const TemplatesPageView: FC<TemplatesPageViewProps> = ({
|
||||||
error,
|
error,
|
||||||
|
filter,
|
||||||
showOrganizations,
|
showOrganizations,
|
||||||
canCreateTemplates,
|
canCreateTemplates,
|
||||||
examples,
|
examples,
|
||||||
@ -213,13 +217,13 @@ export const TemplatesPageView: FC<TemplatesPageViewProps> = ({
|
|||||||
<TemplateHelpTooltip />
|
<TemplateHelpTooltip />
|
||||||
</Stack>
|
</Stack>
|
||||||
</PageHeaderTitle>
|
</PageHeaderTitle>
|
||||||
{templates && templates.length > 0 && (
|
|
||||||
<PageHeaderSubtitle>
|
<PageHeaderSubtitle>
|
||||||
Select a template to create a workspace.
|
Select a template to create a workspace.
|
||||||
</PageHeaderSubtitle>
|
</PageHeaderSubtitle>
|
||||||
)}
|
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
<TemplatesFilter filter={filter} />
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<ErrorAlert error={error} />
|
<ErrorAlert error={error} />
|
||||||
) : (
|
) : (
|
||||||
|
@ -6,7 +6,7 @@ import StopOutlined from "@mui/icons-material/StopOutlined";
|
|||||||
import LoadingButton from "@mui/lab/LoadingButton";
|
import LoadingButton from "@mui/lab/LoadingButton";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import Divider from "@mui/material/Divider";
|
import Divider from "@mui/material/Divider";
|
||||||
import type { ComponentProps } from "react";
|
import type { ComponentProps, FC } from "react";
|
||||||
import type { UseQueryResult } from "react-query";
|
import type { UseQueryResult } from "react-query";
|
||||||
import { hasError, isApiValidationError } from "api/errors";
|
import { hasError, isApiValidationError } from "api/errors";
|
||||||
import type { Template, Workspace } from "api/typesGenerated";
|
import type { Template, Workspace } from "api/typesGenerated";
|
||||||
@ -65,7 +65,7 @@ export interface WorkspacesPageViewProps {
|
|||||||
canChangeVersions: boolean;
|
canChangeVersions: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WorkspacesPageView = ({
|
export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({
|
||||||
workspaces,
|
workspaces,
|
||||||
error,
|
error,
|
||||||
limit,
|
limit,
|
||||||
@ -86,7 +86,7 @@ export const WorkspacesPageView = ({
|
|||||||
templatesFetchStatus,
|
templatesFetchStatus,
|
||||||
canCreateTemplate,
|
canCreateTemplate,
|
||||||
canChangeVersions,
|
canChangeVersions,
|
||||||
}: WorkspacesPageViewProps) => {
|
}) => {
|
||||||
// Let's say the user has 5 workspaces, but tried to hit page 100, which does
|
// 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
|
// not exist. In this case, the page is not valid and we want to show a better
|
||||||
// error message.
|
// error message.
|
||||||
|
@ -43,8 +43,7 @@ export const useTemplateFilterMenu = ({
|
|||||||
template.display_name.toLowerCase().includes(query.toLowerCase()),
|
template.display_name.toLowerCase().includes(query.toLowerCase()),
|
||||||
);
|
);
|
||||||
return filteredTemplates.map((template) => ({
|
return filteredTemplates.map((template) => ({
|
||||||
label:
|
label: template.display_name || template.name,
|
||||||
template.display_name !== "" ? template.display_name : template.name,
|
|
||||||
value: template.name,
|
value: template.name,
|
||||||
startIcon: <TemplateAvatar size="xs" template={template} />,
|
startIcon: <TemplateAvatar size="xs" template={template} />,
|
||||||
}));
|
}));
|
||||||
|
Reference in New Issue
Block a user