mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: filter templates by organization (#14254)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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[]>(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -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} />,
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user