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
+5 -5
View File
@@ -7900,11 +7900,11 @@ WHERE
LOWER("name") = LOWER($3)
ELSE true
END
-- Filter by name, matching on substring
AND CASE
WHEN $4 :: text != '' THEN
lower(name) ILIKE '%' || lower($4) || '%'
ELSE true
-- Filter by name, matching on substring
AND CASE
WHEN $4 :: text != '' THEN
lower(name) ILIKE '%' || lower($4) || '%'
ELSE true
END
-- Filter by ids
AND CASE
+5 -5
View File
@@ -28,11 +28,11 @@ WHERE
LOWER("name") = LOWER(@exact_name)
ELSE true
END
-- Filter by name, matching on substring
AND CASE
WHEN @fuzzy_name :: text != '' THEN
lower(name) ILIKE '%' || lower(@fuzzy_name) || '%'
ELSE true
-- Filter by name, matching on substring
AND CASE
WHEN @fuzzy_name :: text != '' THEN
lower(name) ILIKE '%' || lower(@fuzzy_name) || '%'
ELSE true
END
-- Filter by ids
AND CASE
+1 -1
View File
@@ -198,9 +198,9 @@ func Templates(ctx context.Context, db database.Store, query string) (database.G
parser := httpapi.NewQueryParamParser()
filter := database.GetTemplatesWithFilterParams{
FuzzyName: parser.String(values, "", "name"),
Deleted: parser.Boolean(values, false, "deleted"),
ExactName: parser.String(values, "", "exact_name"),
FuzzyName: parser.String(values, "", "name"),
IDs: parser.UUIDs(values, []uuid.UUID{}, "ids"),
Deprecated: parser.NullableBoolean(values, sql.NullBool{}, "deprecated"),
}
+17 -2
View File
@@ -304,9 +304,17 @@ export type GetTemplatesOptions = Readonly<{
readonly deprecated?: boolean;
}>;
export type GetTemplatesQuery = Readonly<{
readonly q: string;
}>;
function normalizeGetTemplatesOptions(
options: GetTemplatesOptions = {},
options: GetTemplatesOptions | GetTemplatesQuery = {},
): Record<string, string> {
if ("q" in options) {
return options;
}
const params: Record<string, string> = {};
if (options.deprecated !== undefined) {
params["deprecated"] = String(options.deprecated);
@@ -666,6 +674,13 @@ class ApiMethods {
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
*/
@@ -687,7 +702,7 @@ class ApiMethods {
};
getTemplates = async (
options?: GetTemplatesOptions,
options?: GetTemplatesOptions | GetTemplatesQuery,
): Promise<TypesGen.Template[]> => {
const params = normalizeGetTemplatesOptions(options);
const response = await this.axios.get<TypesGen.Template[]>(
+7 -6
View File
@@ -1,5 +1,5 @@
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 {
CreateTemplateRequest,
CreateTemplateVersionRequest,
@@ -38,12 +38,13 @@ export const templateByName = (
};
};
const getTemplatesQueryKey = (options?: GetTemplatesOptions) => [
"templates",
options?.deprecated,
];
const getTemplatesQueryKey = (
options?: GetTemplatesOptions | GetTemplatesQuery,
) => ["templates", options];
export const templates = (options?: GetTemplatesOptions) => {
export const templates = (
options?: GetTemplatesOptions | GetTemplatesQuery,
) => {
return {
queryKey: getTemplatesQueryKey(options),
queryFn: () => API.getTemplates(options),
+19 -15
View File
@@ -137,7 +137,7 @@ type FilterProps = {
filter: ReturnType<typeof useFilter>;
skeleton: ReactNode;
isLoading: boolean;
learnMoreLink: string;
learnMoreLink?: string;
learnMoreLabel2?: string;
learnMoreLink2?: string;
error?: unknown;
@@ -240,7 +240,7 @@ export const Filter: FC<FilterProps> = ({
interface PresetMenuProps {
presets: PresetFilter[];
learnMoreLink: string;
learnMoreLink?: string;
learnMoreLabel2?: string;
learnMoreLink2?: string;
onSelect: (query: string) => void;
@@ -293,19 +293,23 @@ const PresetMenu: FC<PresetMenuProps> = ({
{presetFilter.name}
</MenuItem>
))}
<Divider css={{ borderColor: theme.palette.divider }} />
<MenuItem
component="a"
href={learnMoreLink}
target="_blank"
css={{ fontSize: 13, fontWeight: 500 }}
onClick={() => {
setIsOpen(false);
}}
>
<OpenInNewOutlined css={{ fontSize: "14px !important" }} />
View advanced filtering
</MenuItem>
{learnMoreLink && (
<>
<Divider css={{ borderColor: theme.palette.divider }} />
<MenuItem
component="a"
href={learnMoreLink}
target="_blank"
css={{ fontSize: 13, fontWeight: 500 }}
onClick={() => {
setIsOpen(false);
}}
>
<OpenInNewOutlined css={{ fontSize: "14px !important" }} />
View advanced filtering
</MenuItem>
</>
)}
{learnMoreLink2 && learnMoreLabel2 && (
<MenuItem
component="a"
@@ -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}
/>
),
});
+11 -1
View File
@@ -1,7 +1,9 @@
import type { FC } from "react";
import { Helmet } from "react-helmet-async";
import { useQuery } from "react-query";
import { useSearchParams } from "react-router-dom";
import { templateExamples, templates } from "api/queries/templates";
import { useFilter } from "components/Filter/filter";
import { useAuthenticated } from "contexts/auth/RequireAuth";
import { useDashboard } from "modules/dashboard/useDashboard";
import { pageTitle } from "utils/page";
@@ -11,7 +13,14 @@ export const TemplatesPage: FC = () => {
const { permissions } = useAuthenticated();
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({
...templateExamples(),
enabled: permissions.createTemplates,
@@ -25,6 +34,7 @@ export const TemplatesPage: FC = () => {
</Helmet>
<TemplatesPageView
error={error}
filter={filter}
showOrganizations={showOrganizations}
canCreateTemplates={permissions.createTemplates}
examples={examplesQuery.data}
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
import { getDefaultFilterProps } from "components/Filter/storyHelpers";
import { chromaticWithTablet } from "testHelpers/chromatic";
import {
mockApiError,
@@ -14,6 +15,13 @@ const meta: Meta<typeof TemplatesPageView> = {
decorators: [withDashboardProvider],
parameters: { chromatic: chromaticWithTablet },
component: TemplatesPageView,
args: {
...getDefaultFilterProps({
query: "deprecated:false",
menus: {},
values: {},
}),
},
};
export default meta;
@@ -17,6 +17,7 @@ import { ExternalAvatar } from "components/Avatar/Avatar";
import { AvatarData } from "components/AvatarData/AvatarData";
import { AvatarDataSkeleton } from "components/AvatarData/AvatarDataSkeleton";
import { DeprecatedBadge } from "components/Badges/Badges";
import type { useFilter } from "components/Filter/filter";
import {
HelpTooltip,
HelpTooltipContent,
@@ -46,6 +47,7 @@ import {
formatTemplateActiveDevelopers,
} from "utils/templates";
import { EmptyTemplates } from "./EmptyTemplates";
import { TemplatesFilter } from "./TemplatesFilter";
export const Language = {
developerCount: (activeCount: number): string => {
@@ -173,6 +175,7 @@ const TemplateRow: FC<TemplateRowProps> = ({ showOrganizations, template }) => {
export interface TemplatesPageViewProps {
error?: unknown;
filter: ReturnType<typeof useFilter>;
showOrganizations: boolean;
canCreateTemplates: boolean;
examples: TemplateExample[] | undefined;
@@ -181,6 +184,7 @@ export interface TemplatesPageViewProps {
export const TemplatesPageView: FC<TemplatesPageViewProps> = ({
error,
filter,
showOrganizations,
canCreateTemplates,
examples,
@@ -213,13 +217,13 @@ export const TemplatesPageView: FC<TemplatesPageViewProps> = ({
<TemplateHelpTooltip />
</Stack>
</PageHeaderTitle>
{templates && templates.length > 0 && (
<PageHeaderSubtitle>
Select a template to create a workspace.
</PageHeaderSubtitle>
)}
<PageHeaderSubtitle>
Select a template to create a workspace.
</PageHeaderSubtitle>
</PageHeader>
<TemplatesFilter filter={filter} />
{error ? (
<ErrorAlert error={error} />
) : (
@@ -6,7 +6,7 @@ import StopOutlined from "@mui/icons-material/StopOutlined";
import LoadingButton from "@mui/lab/LoadingButton";
import Button from "@mui/material/Button";
import Divider from "@mui/material/Divider";
import type { ComponentProps } from "react";
import type { ComponentProps, FC } from "react";
import type { UseQueryResult } from "react-query";
import { hasError, isApiValidationError } from "api/errors";
import type { Template, Workspace } from "api/typesGenerated";
@@ -65,7 +65,7 @@ export interface WorkspacesPageViewProps {
canChangeVersions: boolean;
}
export const WorkspacesPageView = ({
export const WorkspacesPageView: FC<WorkspacesPageViewProps> = ({
workspaces,
error,
limit,
@@ -86,7 +86,7 @@ export const WorkspacesPageView = ({
templatesFetchStatus,
canCreateTemplate,
canChangeVersions,
}: WorkspacesPageViewProps) => {
}) => {
// 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
// error message.
@@ -43,8 +43,7 @@ export const useTemplateFilterMenu = ({
template.display_name.toLowerCase().includes(query.toLowerCase()),
);
return filteredTemplates.map((template) => ({
label:
template.display_name !== "" ? template.display_name : template.name,
label: template.display_name || template.name,
value: template.name,
startIcon: <TemplateAvatar size="xs" template={template} />,
}));