Skip to content

Commit 554c4ab

Browse files
authored
feat: implement multi-org template gallery (coder#13784)
* feat: initial changes for multi-org templates page * feat: add TemplateCard component * feat: add component stories * chore: update template query naming * fix: fix formatting * feat: template card interaction and navigation * fix: copy updates * chore: update TemplateFilter type to include FilterQuery * chore: update typesGenerated.ts * feat: update template filter api logic * fix: fix format * fix: get activeOrg * fix: add format annotation * chore: use organization display name * feat: client side org filtering * fix: use org display name * fix: add ExactName * feat: show orgs filter only if more than 1 org * chore: updates for PR review * fix: fix format * chore: add story for multi org * fix: aggregate templates by organization id * fix: fix format * fix: check org count * fix: update ExactName type
1 parent 40609c2 commit 554c4ab

21 files changed

+663
-64
lines changed

codersdk/organizations.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -400,8 +400,9 @@ func (c *Client) TemplatesByOrganization(ctx context.Context, organizationID uui
400400
}
401401

402402
type TemplateFilter struct {
403-
OrganizationID uuid.UUID
404-
ExactName string
403+
OrganizationID uuid.UUID `json:"organization_id,omitempty" format:"uuid" typescript:"-"`
404+
FilterQuery string `json:"q,omitempty"`
405+
ExactName string `json:"exact_name,omitempty" typescript:"-"`
405406
}
406407

407408
// asRequestOption returns a function that can be used in (*Client).Request.
@@ -419,6 +420,11 @@ func (f TemplateFilter) asRequestOption() RequestOption {
419420
params = append(params, fmt.Sprintf("exact_name:%q", f.ExactName))
420421
}
421422

423+
if f.FilterQuery != "" {
424+
// If custom stuff is added, just add it on here.
425+
params = append(params, f.FilterQuery)
426+
}
427+
422428
q := r.URL.Query()
423429
q.Set("q", strings.Join(params, " "))
424430
r.URL.RawQuery = q.Encode()

site/src/api/api.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -578,7 +578,7 @@ class ApiMethods {
578578
return response.data;
579579
};
580580

581-
getTemplates = async (
581+
getTemplatesByOrganizationId = async (
582582
organizationId: string,
583583
options?: TemplateOptions,
584584
): Promise<TypesGen.Template[]> => {
@@ -598,6 +598,14 @@ class ApiMethods {
598598
return response.data;
599599
};
600600

601+
getTemplates = async (
602+
options?: TypesGen.TemplateFilter,
603+
): Promise<TypesGen.Template[]> => {
604+
const url = getURLWithSearchParams("/api/v2/templates", options);
605+
const response = await this.axios.get<TypesGen.Template[]>(url);
606+
return response.data;
607+
};
608+
601609
getTemplateByName = async (
602610
organizationId: string,
603611
name: string,

site/src/api/queries/audits.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { API } from "api/api";
22
import type { AuditLogResponse } from "api/typesGenerated";
3-
import { useFilterParamsKey } from "components/Filter/filter";
43
import type { UsePaginatedQueryOptions } from "hooks/usePaginatedQuery";
4+
import { filterParamsKey } from "utils/filters";
55

66
export function paginatedAudits(
77
searchParams: URLSearchParams,
88
): UsePaginatedQueryOptions<AuditLogResponse, string> {
99
return {
1010
searchParams,
11-
queryPayload: () => searchParams.get(useFilterParamsKey) ?? "",
11+
queryPayload: () => searchParams.get(filterParamsKey) ?? "",
1212
queryKey: ({ payload, pageNumber }) => {
1313
return ["auditLogs", payload, pageNumber] as const;
1414
},

site/src/api/queries/templates.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { MutationOptions, QueryClient, QueryOptions } from "react-query";
22
import { API } from "api/api";
33
import type {
4+
TemplateFilter,
45
CreateTemplateRequest,
56
CreateTemplateVersionRequest,
67
ProvisionerJob,
@@ -30,16 +31,26 @@ export const templateByName = (
3031
};
3132
};
3233

33-
const getTemplatesQueryKey = (organizationId: string, deprecated?: boolean) => [
34-
organizationId,
35-
"templates",
36-
deprecated,
37-
];
34+
const getTemplatesByOrganizationIdQueryKey = (
35+
organizationId: string,
36+
deprecated?: boolean,
37+
) => [organizationId, "templates", deprecated];
38+
39+
export const templatesByOrganizationId = (
40+
organizationId: string,
41+
deprecated?: boolean,
42+
) => {
43+
return {
44+
queryKey: getTemplatesByOrganizationIdQueryKey(organizationId, deprecated),
45+
queryFn: () =>
46+
API.getTemplatesByOrganizationId(organizationId, { deprecated }),
47+
};
48+
};
3849

39-
export const templates = (organizationId: string, deprecated?: boolean) => {
50+
export const templates = (filter?: TemplateFilter) => {
4051
return {
41-
queryKey: getTemplatesQueryKey(organizationId, deprecated),
42-
queryFn: () => API.getTemplates(organizationId, { deprecated }),
52+
queryKey: ["templates", filter],
53+
queryFn: () => API.getTemplates(filter),
4354
};
4455
};
4556

@@ -92,7 +103,10 @@ export const setGroupRole = (
92103

93104
export const templateExamples = (organizationId: string) => {
94105
return {
95-
queryKey: [...getTemplatesQueryKey(organizationId), "examples"],
106+
queryKey: [
107+
...getTemplatesByOrganizationIdQueryKey(organizationId),
108+
"examples",
109+
],
96110
queryFn: () => API.getTemplateExamples(organizationId),
97111
};
98112
};

site/src/api/typesGenerated.ts

Lines changed: 1 addition & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

site/src/components/Filter/filter.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { InputGroup } from "components/InputGroup/InputGroup";
1717
import { SearchField } from "components/SearchField/SearchField";
1818
import { useDebouncedFunction } from "hooks/debounce";
19+
import { filterParamsKey } from "utils/filters";
1920

2021
export type PresetFilter = {
2122
name: string;
@@ -35,21 +36,19 @@ type UseFilterConfig = {
3536
onUpdate?: (newValue: string) => void;
3637
};
3738

38-
export const useFilterParamsKey = "filter";
39-
4039
export const useFilter = ({
4140
fallbackFilter = "",
4241
searchParamsResult,
4342
onUpdate,
4443
}: UseFilterConfig) => {
4544
const [searchParams, setSearchParams] = searchParamsResult;
46-
const query = searchParams.get(useFilterParamsKey) ?? fallbackFilter;
45+
const query = searchParams.get(filterParamsKey) ?? fallbackFilter;
4746

4847
const update = (newValues: string | FilterValues) => {
4948
const serialized =
5049
typeof newValues === "string" ? newValues : stringifyFilter(newValues);
5150

52-
searchParams.set(useFilterParamsKey, serialized);
51+
searchParams.set(filterParamsKey, serialized);
5352
setSearchParams(searchParams);
5453

5554
if (onUpdate !== undefined) {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { chromatic } from "testHelpers/chromatic";
3+
import { MockTemplate } from "testHelpers/entities";
4+
import { TemplateCard } from "./TemplateCard";
5+
6+
const meta: Meta<typeof TemplateCard> = {
7+
title: "modules/templates/TemplateCard",
8+
parameters: { chromatic },
9+
component: TemplateCard,
10+
args: {
11+
template: MockTemplate,
12+
},
13+
};
14+
15+
export default meta;
16+
type Story = StoryObj<typeof TemplateCard>;
17+
18+
export const Template: Story = {};
19+
20+
export const DeprecatedTemplate: Story = {
21+
args: {
22+
template: {
23+
...MockTemplate,
24+
deprecated: true,
25+
},
26+
},
27+
};
28+
29+
export const LongContentTemplate: Story = {
30+
args: {
31+
template: {
32+
...MockTemplate,
33+
display_name: "Very Long Template Name",
34+
organization_display_name: "Very Long Organization Name",
35+
description:
36+
"This is a very long test description. This is a very long test description. This is a very long test description. This is a very long test description",
37+
active_user_count: 999,
38+
},
39+
},
40+
};
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import type { Interpolation, Theme } from "@emotion/react";
2+
import ArrowForwardOutlined from "@mui/icons-material/ArrowForwardOutlined";
3+
import Button from "@mui/material/Button";
4+
import type { FC, HTMLAttributes } from "react";
5+
import { Link as RouterLink, useNavigate } from "react-router-dom";
6+
import type { Template } from "api/typesGenerated";
7+
import { ExternalAvatar } from "components/Avatar/Avatar";
8+
import { AvatarData } from "components/AvatarData/AvatarData";
9+
import { DeprecatedBadge } from "components/Badges/Badges";
10+
11+
type TemplateCardProps = HTMLAttributes<HTMLDivElement> & {
12+
template: Template;
13+
};
14+
15+
export const TemplateCard: FC<TemplateCardProps> = ({
16+
template,
17+
...divProps
18+
}) => {
19+
const navigate = useNavigate();
20+
const templatePageLink = `/templates/${template.name}`;
21+
const hasIcon = template.icon && template.icon !== "";
22+
23+
const handleKeyDown = (e: React.KeyboardEvent) => {
24+
if (e.key === "Enter" && e.currentTarget === e.target) {
25+
navigate(templatePageLink);
26+
}
27+
};
28+
29+
return (
30+
<div
31+
css={styles.card}
32+
{...divProps}
33+
role="button"
34+
tabIndex={0}
35+
onClick={() => navigate(templatePageLink)}
36+
onKeyDown={handleKeyDown}
37+
>
38+
<div css={styles.header}>
39+
<div>
40+
<AvatarData
41+
title={
42+
template.display_name.length > 0
43+
? template.display_name
44+
: template.name
45+
}
46+
subtitle={template.organization_display_name}
47+
avatar={
48+
hasIcon && (
49+
<ExternalAvatar variant="square" fitImage src={template.icon} />
50+
)
51+
}
52+
/>
53+
</div>
54+
<div>
55+
{template.active_user_count}{" "}
56+
{template.active_user_count === 1 ? "user" : "users"}
57+
</div>
58+
</div>
59+
60+
<div>
61+
<span css={styles.description}>
62+
<p>{template.description}</p>
63+
</span>
64+
</div>
65+
66+
<div css={styles.useButtonContainer}>
67+
{template.deprecated ? (
68+
<DeprecatedBadge />
69+
) : (
70+
<Button
71+
component={RouterLink}
72+
css={styles.actionButton}
73+
className="actionButton"
74+
fullWidth
75+
startIcon={<ArrowForwardOutlined />}
76+
title={`Create a workspace using the ${template.display_name} template`}
77+
to={`/templates/${template.name}/workspace`}
78+
onClick={(e) => {
79+
e.stopPropagation();
80+
}}
81+
>
82+
Create Workspace
83+
</Button>
84+
)}
85+
</div>
86+
</div>
87+
);
88+
};
89+
90+
const styles = {
91+
card: (theme) => ({
92+
width: "320px",
93+
padding: 24,
94+
borderRadius: 6,
95+
border: `1px solid ${theme.palette.divider}`,
96+
textAlign: "left",
97+
color: "inherit",
98+
display: "flex",
99+
flexDirection: "column",
100+
cursor: "pointer",
101+
"&:hover": {
102+
color: theme.experimental.l2.hover.text,
103+
borderColor: theme.experimental.l2.hover.text,
104+
},
105+
}),
106+
107+
header: {
108+
display: "flex",
109+
alignItems: "center",
110+
justifyContent: "space-between",
111+
marginBottom: 24,
112+
},
113+
114+
icon: {
115+
flexShrink: 0,
116+
paddingTop: 4,
117+
width: 32,
118+
height: 32,
119+
},
120+
121+
description: (theme) => ({
122+
fontSize: 13,
123+
color: theme.palette.text.secondary,
124+
lineHeight: "1.6",
125+
display: "block",
126+
}),
127+
128+
useButtonContainer: {
129+
display: "flex",
130+
gap: 12,
131+
flexDirection: "column",
132+
paddingTop: 24,
133+
marginTop: "auto",
134+
alignItems: "center",
135+
},
136+
137+
actionButton: (theme) => ({
138+
transition: "none",
139+
color: theme.palette.text.secondary,
140+
"&:hover": {
141+
borderColor: theme.palette.text.primary,
142+
},
143+
}),
144+
} satisfies Record<string, Interpolation<Theme>>;

site/src/pages/StarterTemplatesPage/StarterTemplatesPage.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { templateExamples } from "api/queries/templates";
55
import type { TemplateExample } from "api/typesGenerated";
66
import { useDashboard } from "modules/dashboard/useDashboard";
77
import { pageTitle } from "utils/page";
8-
import { getTemplatesByTag } from "utils/starterTemplates";
8+
import { getTemplatesByTag } from "utils/templateAggregators";
99
import { StarterTemplatesPageView } from "./StarterTemplatesPageView";
1010

1111
const StarterTemplatesPage: FC = () => {

site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
MockTemplateExample,
66
MockTemplateExample2,
77
} from "testHelpers/entities";
8-
import { getTemplatesByTag } from "utils/starterTemplates";
8+
import { getTemplatesByTag } from "utils/templateAggregators";
99
import { StarterTemplatesPageView } from "./StarterTemplatesPageView";
1010

1111
const meta: Meta<typeof StarterTemplatesPageView> = {

site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
} from "components/PageHeader/PageHeader";
1212
import { Stack } from "components/Stack/Stack";
1313
import { TemplateExampleCard } from "modules/templates/TemplateExampleCard/TemplateExampleCard";
14-
import type { StarterTemplatesByTag } from "utils/starterTemplates";
14+
import type { StarterTemplatesByTag } from "utils/templateAggregators";
1515

1616
const getTagLabel = (tag: string) => {
1717
const labelByTag: Record<string, string> = {

0 commit comments

Comments
 (0)