feat: filter templates by organization (#14254)

This commit is contained in:
Kayla Washburn-Love
2024-08-14 15:01:45 -06:00
committed by GitHub
parent 4fc047954e
commit 8563b372e8
12 changed files with 169 additions and 45 deletions

View File

@ -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"),
} }

View File

@ -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[]>(

View File

@ -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),

View File

@ -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"

View 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}
/>
),
});

View File

@ -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}

View File

@ -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;

View File

@ -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} />
) : ( ) : (

View File

@ -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.

View File

@ -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} />,
})); }));