mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
feat: implement multi-org template gallery (#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
This commit is contained in:
@ -400,8 +400,9 @@ func (c *Client) TemplatesByOrganization(ctx context.Context, organizationID uui
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TemplateFilter struct {
|
type TemplateFilter struct {
|
||||||
OrganizationID uuid.UUID
|
OrganizationID uuid.UUID `json:"organization_id,omitempty" format:"uuid" typescript:"-"`
|
||||||
ExactName string
|
FilterQuery string `json:"q,omitempty"`
|
||||||
|
ExactName string `json:"exact_name,omitempty" typescript:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// asRequestOption returns a function that can be used in (*Client).Request.
|
// asRequestOption returns a function that can be used in (*Client).Request.
|
||||||
@ -419,6 +420,11 @@ func (f TemplateFilter) asRequestOption() RequestOption {
|
|||||||
params = append(params, fmt.Sprintf("exact_name:%q", f.ExactName))
|
params = append(params, fmt.Sprintf("exact_name:%q", f.ExactName))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if f.FilterQuery != "" {
|
||||||
|
// If custom stuff is added, just add it on here.
|
||||||
|
params = append(params, f.FilterQuery)
|
||||||
|
}
|
||||||
|
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
q.Set("q", strings.Join(params, " "))
|
q.Set("q", strings.Join(params, " "))
|
||||||
r.URL.RawQuery = q.Encode()
|
r.URL.RawQuery = q.Encode()
|
||||||
|
@ -578,7 +578,7 @@ class ApiMethods {
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
getTemplates = async (
|
getTemplatesByOrganizationId = async (
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
options?: TemplateOptions,
|
options?: TemplateOptions,
|
||||||
): Promise<TypesGen.Template[]> => {
|
): Promise<TypesGen.Template[]> => {
|
||||||
@ -598,6 +598,14 @@ class ApiMethods {
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
getTemplates = async (
|
||||||
|
options?: TypesGen.TemplateFilter,
|
||||||
|
): Promise<TypesGen.Template[]> => {
|
||||||
|
const url = getURLWithSearchParams("/api/v2/templates", options);
|
||||||
|
const response = await this.axios.get<TypesGen.Template[]>(url);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
getTemplateByName = async (
|
getTemplateByName = async (
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
name: string,
|
name: string,
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import { API } from "api/api";
|
import { API } from "api/api";
|
||||||
import type { AuditLogResponse } from "api/typesGenerated";
|
import type { AuditLogResponse } from "api/typesGenerated";
|
||||||
import { useFilterParamsKey } from "components/Filter/filter";
|
|
||||||
import type { UsePaginatedQueryOptions } from "hooks/usePaginatedQuery";
|
import type { UsePaginatedQueryOptions } from "hooks/usePaginatedQuery";
|
||||||
|
import { filterParamsKey } from "utils/filters";
|
||||||
|
|
||||||
export function paginatedAudits(
|
export function paginatedAudits(
|
||||||
searchParams: URLSearchParams,
|
searchParams: URLSearchParams,
|
||||||
): UsePaginatedQueryOptions<AuditLogResponse, string> {
|
): UsePaginatedQueryOptions<AuditLogResponse, string> {
|
||||||
return {
|
return {
|
||||||
searchParams,
|
searchParams,
|
||||||
queryPayload: () => searchParams.get(useFilterParamsKey) ?? "",
|
queryPayload: () => searchParams.get(filterParamsKey) ?? "",
|
||||||
queryKey: ({ payload, pageNumber }) => {
|
queryKey: ({ payload, pageNumber }) => {
|
||||||
return ["auditLogs", payload, pageNumber] as const;
|
return ["auditLogs", payload, pageNumber] as const;
|
||||||
},
|
},
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import type { MutationOptions, QueryClient, QueryOptions } from "react-query";
|
import type { MutationOptions, QueryClient, QueryOptions } from "react-query";
|
||||||
import { API } from "api/api";
|
import { API } from "api/api";
|
||||||
import type {
|
import type {
|
||||||
|
TemplateFilter,
|
||||||
CreateTemplateRequest,
|
CreateTemplateRequest,
|
||||||
CreateTemplateVersionRequest,
|
CreateTemplateVersionRequest,
|
||||||
ProvisionerJob,
|
ProvisionerJob,
|
||||||
@ -30,16 +31,26 @@ export const templateByName = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTemplatesQueryKey = (organizationId: string, deprecated?: boolean) => [
|
const getTemplatesByOrganizationIdQueryKey = (
|
||||||
organizationId,
|
organizationId: string,
|
||||||
"templates",
|
deprecated?: boolean,
|
||||||
deprecated,
|
) => [organizationId, "templates", deprecated];
|
||||||
];
|
|
||||||
|
|
||||||
export const templates = (organizationId: string, deprecated?: boolean) => {
|
export const templatesByOrganizationId = (
|
||||||
|
organizationId: string,
|
||||||
|
deprecated?: boolean,
|
||||||
|
) => {
|
||||||
return {
|
return {
|
||||||
queryKey: getTemplatesQueryKey(organizationId, deprecated),
|
queryKey: getTemplatesByOrganizationIdQueryKey(organizationId, deprecated),
|
||||||
queryFn: () => API.getTemplates(organizationId, { deprecated }),
|
queryFn: () =>
|
||||||
|
API.getTemplatesByOrganizationId(organizationId, { deprecated }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const templates = (filter?: TemplateFilter) => {
|
||||||
|
return {
|
||||||
|
queryKey: ["templates", filter],
|
||||||
|
queryFn: () => API.getTemplates(filter),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -92,7 +103,10 @@ export const setGroupRole = (
|
|||||||
|
|
||||||
export const templateExamples = (organizationId: string) => {
|
export const templateExamples = (organizationId: string) => {
|
||||||
return {
|
return {
|
||||||
queryKey: [...getTemplatesQueryKey(organizationId), "examples"],
|
queryKey: [
|
||||||
|
...getTemplatesByOrganizationIdQueryKey(organizationId),
|
||||||
|
"examples",
|
||||||
|
],
|
||||||
queryFn: () => API.getTemplateExamples(organizationId),
|
queryFn: () => API.getTemplateExamples(organizationId),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
3
site/src/api/typesGenerated.ts
generated
3
site/src/api/typesGenerated.ts
generated
@ -1246,8 +1246,7 @@ export interface TemplateExample {
|
|||||||
|
|
||||||
// From codersdk/organizations.go
|
// From codersdk/organizations.go
|
||||||
export interface TemplateFilter {
|
export interface TemplateFilter {
|
||||||
readonly OrganizationID: string;
|
readonly q?: string;
|
||||||
readonly ExactName: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// From codersdk/templates.go
|
// From codersdk/templates.go
|
||||||
|
@ -16,6 +16,7 @@ import {
|
|||||||
import { InputGroup } from "components/InputGroup/InputGroup";
|
import { InputGroup } from "components/InputGroup/InputGroup";
|
||||||
import { SearchField } from "components/SearchField/SearchField";
|
import { SearchField } from "components/SearchField/SearchField";
|
||||||
import { useDebouncedFunction } from "hooks/debounce";
|
import { useDebouncedFunction } from "hooks/debounce";
|
||||||
|
import { filterParamsKey } from "utils/filters";
|
||||||
|
|
||||||
export type PresetFilter = {
|
export type PresetFilter = {
|
||||||
name: string;
|
name: string;
|
||||||
@ -35,21 +36,19 @@ type UseFilterConfig = {
|
|||||||
onUpdate?: (newValue: string) => void;
|
onUpdate?: (newValue: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useFilterParamsKey = "filter";
|
|
||||||
|
|
||||||
export const useFilter = ({
|
export const useFilter = ({
|
||||||
fallbackFilter = "",
|
fallbackFilter = "",
|
||||||
searchParamsResult,
|
searchParamsResult,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
}: UseFilterConfig) => {
|
}: UseFilterConfig) => {
|
||||||
const [searchParams, setSearchParams] = searchParamsResult;
|
const [searchParams, setSearchParams] = searchParamsResult;
|
||||||
const query = searchParams.get(useFilterParamsKey) ?? fallbackFilter;
|
const query = searchParams.get(filterParamsKey) ?? fallbackFilter;
|
||||||
|
|
||||||
const update = (newValues: string | FilterValues) => {
|
const update = (newValues: string | FilterValues) => {
|
||||||
const serialized =
|
const serialized =
|
||||||
typeof newValues === "string" ? newValues : stringifyFilter(newValues);
|
typeof newValues === "string" ? newValues : stringifyFilter(newValues);
|
||||||
|
|
||||||
searchParams.set(useFilterParamsKey, serialized);
|
searchParams.set(filterParamsKey, serialized);
|
||||||
setSearchParams(searchParams);
|
setSearchParams(searchParams);
|
||||||
|
|
||||||
if (onUpdate !== undefined) {
|
if (onUpdate !== undefined) {
|
||||||
|
@ -0,0 +1,40 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/react";
|
||||||
|
import { chromatic } from "testHelpers/chromatic";
|
||||||
|
import { MockTemplate } from "testHelpers/entities";
|
||||||
|
import { TemplateCard } from "./TemplateCard";
|
||||||
|
|
||||||
|
const meta: Meta<typeof TemplateCard> = {
|
||||||
|
title: "modules/templates/TemplateCard",
|
||||||
|
parameters: { chromatic },
|
||||||
|
component: TemplateCard,
|
||||||
|
args: {
|
||||||
|
template: MockTemplate,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof TemplateCard>;
|
||||||
|
|
||||||
|
export const Template: Story = {};
|
||||||
|
|
||||||
|
export const DeprecatedTemplate: Story = {
|
||||||
|
args: {
|
||||||
|
template: {
|
||||||
|
...MockTemplate,
|
||||||
|
deprecated: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LongContentTemplate: Story = {
|
||||||
|
args: {
|
||||||
|
template: {
|
||||||
|
...MockTemplate,
|
||||||
|
display_name: "Very Long Template Name",
|
||||||
|
organization_display_name: "Very Long Organization Name",
|
||||||
|
description:
|
||||||
|
"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",
|
||||||
|
active_user_count: 999,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
144
site/src/modules/templates/TemplateCard/TemplateCard.tsx
Normal file
144
site/src/modules/templates/TemplateCard/TemplateCard.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import type { Interpolation, Theme } from "@emotion/react";
|
||||||
|
import ArrowForwardOutlined from "@mui/icons-material/ArrowForwardOutlined";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import type { FC, HTMLAttributes } from "react";
|
||||||
|
import { Link as RouterLink, useNavigate } from "react-router-dom";
|
||||||
|
import type { Template } from "api/typesGenerated";
|
||||||
|
import { ExternalAvatar } from "components/Avatar/Avatar";
|
||||||
|
import { AvatarData } from "components/AvatarData/AvatarData";
|
||||||
|
import { DeprecatedBadge } from "components/Badges/Badges";
|
||||||
|
|
||||||
|
type TemplateCardProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
template: Template;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TemplateCard: FC<TemplateCardProps> = ({
|
||||||
|
template,
|
||||||
|
...divProps
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const templatePageLink = `/templates/${template.name}`;
|
||||||
|
const hasIcon = template.icon && template.icon !== "";
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && e.currentTarget === e.target) {
|
||||||
|
navigate(templatePageLink);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
css={styles.card}
|
||||||
|
{...divProps}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => navigate(templatePageLink)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<div css={styles.header}>
|
||||||
|
<div>
|
||||||
|
<AvatarData
|
||||||
|
title={
|
||||||
|
template.display_name.length > 0
|
||||||
|
? template.display_name
|
||||||
|
: template.name
|
||||||
|
}
|
||||||
|
subtitle={template.organization_display_name}
|
||||||
|
avatar={
|
||||||
|
hasIcon && (
|
||||||
|
<ExternalAvatar variant="square" fitImage src={template.icon} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{template.active_user_count}{" "}
|
||||||
|
{template.active_user_count === 1 ? "user" : "users"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span css={styles.description}>
|
||||||
|
<p>{template.description}</p>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div css={styles.useButtonContainer}>
|
||||||
|
{template.deprecated ? (
|
||||||
|
<DeprecatedBadge />
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
component={RouterLink}
|
||||||
|
css={styles.actionButton}
|
||||||
|
className="actionButton"
|
||||||
|
fullWidth
|
||||||
|
startIcon={<ArrowForwardOutlined />}
|
||||||
|
title={`Create a workspace using the ${template.display_name} template`}
|
||||||
|
to={`/templates/${template.name}/workspace`}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create Workspace
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
card: (theme) => ({
|
||||||
|
width: "320px",
|
||||||
|
padding: 24,
|
||||||
|
borderRadius: 6,
|
||||||
|
border: `1px solid ${theme.palette.divider}`,
|
||||||
|
textAlign: "left",
|
||||||
|
color: "inherit",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
cursor: "pointer",
|
||||||
|
"&:hover": {
|
||||||
|
color: theme.experimental.l2.hover.text,
|
||||||
|
borderColor: theme.experimental.l2.hover.text,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
header: {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
|
||||||
|
icon: {
|
||||||
|
flexShrink: 0,
|
||||||
|
paddingTop: 4,
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
},
|
||||||
|
|
||||||
|
description: (theme) => ({
|
||||||
|
fontSize: 13,
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
lineHeight: "1.6",
|
||||||
|
display: "block",
|
||||||
|
}),
|
||||||
|
|
||||||
|
useButtonContainer: {
|
||||||
|
display: "flex",
|
||||||
|
gap: 12,
|
||||||
|
flexDirection: "column",
|
||||||
|
paddingTop: 24,
|
||||||
|
marginTop: "auto",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
|
||||||
|
actionButton: (theme) => ({
|
||||||
|
transition: "none",
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
"&:hover": {
|
||||||
|
borderColor: theme.palette.text.primary,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} satisfies Record<string, Interpolation<Theme>>;
|
@ -5,7 +5,7 @@ import { templateExamples } from "api/queries/templates";
|
|||||||
import type { TemplateExample } from "api/typesGenerated";
|
import type { TemplateExample } from "api/typesGenerated";
|
||||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||||
import { pageTitle } from "utils/page";
|
import { pageTitle } from "utils/page";
|
||||||
import { getTemplatesByTag } from "utils/starterTemplates";
|
import { getTemplatesByTag } from "utils/templateAggregators";
|
||||||
import { StarterTemplatesPageView } from "./StarterTemplatesPageView";
|
import { StarterTemplatesPageView } from "./StarterTemplatesPageView";
|
||||||
|
|
||||||
const StarterTemplatesPage: FC = () => {
|
const StarterTemplatesPage: FC = () => {
|
||||||
|
@ -5,7 +5,7 @@ import {
|
|||||||
MockTemplateExample,
|
MockTemplateExample,
|
||||||
MockTemplateExample2,
|
MockTemplateExample2,
|
||||||
} from "testHelpers/entities";
|
} from "testHelpers/entities";
|
||||||
import { getTemplatesByTag } from "utils/starterTemplates";
|
import { getTemplatesByTag } from "utils/templateAggregators";
|
||||||
import { StarterTemplatesPageView } from "./StarterTemplatesPageView";
|
import { StarterTemplatesPageView } from "./StarterTemplatesPageView";
|
||||||
|
|
||||||
const meta: Meta<typeof StarterTemplatesPageView> = {
|
const meta: Meta<typeof StarterTemplatesPageView> = {
|
||||||
|
@ -11,7 +11,7 @@ import {
|
|||||||
} from "components/PageHeader/PageHeader";
|
} from "components/PageHeader/PageHeader";
|
||||||
import { Stack } from "components/Stack/Stack";
|
import { Stack } from "components/Stack/Stack";
|
||||||
import { TemplateExampleCard } from "modules/templates/TemplateExampleCard/TemplateExampleCard";
|
import { TemplateExampleCard } from "modules/templates/TemplateExampleCard/TemplateExampleCard";
|
||||||
import type { StarterTemplatesByTag } from "utils/starterTemplates";
|
import type { StarterTemplatesByTag } from "utils/templateAggregators";
|
||||||
|
|
||||||
const getTagLabel = (tag: string) => {
|
const getTagLabel = (tag: string) => {
|
||||||
const labelByTag: Record<string, string> = {
|
const labelByTag: Record<string, string> = {
|
||||||
|
@ -0,0 +1,154 @@
|
|||||||
|
import type { Meta, StoryObj } from "@storybook/react";
|
||||||
|
import { chromaticWithTablet } from "testHelpers/chromatic";
|
||||||
|
import {
|
||||||
|
mockApiError,
|
||||||
|
MockTemplate,
|
||||||
|
MockTemplateExample,
|
||||||
|
MockTemplateExample2,
|
||||||
|
} from "testHelpers/entities";
|
||||||
|
import { getTemplatesByOrg } from "utils/templateAggregators";
|
||||||
|
import { TemplatesPageView } from "./TemplatesPageView";
|
||||||
|
|
||||||
|
const meta: Meta<typeof TemplatesPageView> = {
|
||||||
|
title: "pages/MultiOrgTemplatesPage",
|
||||||
|
parameters: { chromatic: chromaticWithTablet },
|
||||||
|
component: TemplatesPageView,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof TemplatesPageView>;
|
||||||
|
|
||||||
|
export const WithTemplatesSingleOrgs: Story = {
|
||||||
|
args: {
|
||||||
|
canCreateTemplates: true,
|
||||||
|
error: undefined,
|
||||||
|
templatesByOrg: getTemplatesByOrg([
|
||||||
|
MockTemplate,
|
||||||
|
{
|
||||||
|
...MockTemplate,
|
||||||
|
active_user_count: -1,
|
||||||
|
description: "🚀 Some new template that has no activity data",
|
||||||
|
icon: "/icon/goland.svg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...MockTemplate,
|
||||||
|
active_user_count: 150,
|
||||||
|
description: "😮 Wow, this one has a bunch of usage!",
|
||||||
|
icon: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...MockTemplate,
|
||||||
|
description:
|
||||||
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...MockTemplate,
|
||||||
|
name: "template-without-icon",
|
||||||
|
display_name: "No Icon",
|
||||||
|
description: "This one has no icon",
|
||||||
|
icon: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...MockTemplate,
|
||||||
|
name: "template-without-icon-deprecated",
|
||||||
|
display_name: "Deprecated No Icon",
|
||||||
|
description: "This one has no icon and is deprecated",
|
||||||
|
deprecated: true,
|
||||||
|
deprecation_message: "This template is so old, it's deprecated",
|
||||||
|
icon: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...MockTemplate,
|
||||||
|
name: "deprecated-template",
|
||||||
|
display_name: "Deprecated",
|
||||||
|
description: "Template is incompatible",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
examples: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithTemplatesMultipleOrgs: Story = {
|
||||||
|
args: {
|
||||||
|
canCreateTemplates: true,
|
||||||
|
error: undefined,
|
||||||
|
templatesByOrg: getTemplatesByOrg([
|
||||||
|
MockTemplate,
|
||||||
|
{
|
||||||
|
...MockTemplate,
|
||||||
|
organization_id: "fc0774ce-cc9e-48d4-80ae-88f7a4d4a8a1",
|
||||||
|
organization_name: "first-org",
|
||||||
|
organization_display_name: "First Org",
|
||||||
|
active_user_count: -1,
|
||||||
|
description: "🚀 Some new template that has no activity data",
|
||||||
|
icon: "/icon/goland.svg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...MockTemplate,
|
||||||
|
organization_id: "fc0774ce-cc9e-48d4-80ae-88f7a4d4a8a1",
|
||||||
|
organization_name: "first-org",
|
||||||
|
organization_display_name: "First Org",
|
||||||
|
active_user_count: 150,
|
||||||
|
description: "😮 Wow, this one has a bunch of usage!",
|
||||||
|
icon: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...MockTemplate,
|
||||||
|
description:
|
||||||
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...MockTemplate,
|
||||||
|
name: "template-without-icon",
|
||||||
|
display_name: "No Icon",
|
||||||
|
description: "This one has no icon",
|
||||||
|
icon: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...MockTemplate,
|
||||||
|
name: "template-without-icon-deprecated",
|
||||||
|
display_name: "Deprecated No Icon",
|
||||||
|
description: "This one has no icon and is deprecated",
|
||||||
|
deprecated: true,
|
||||||
|
deprecation_message: "This template is so old, it's deprecated",
|
||||||
|
icon: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...MockTemplate,
|
||||||
|
name: "deprecated-template",
|
||||||
|
display_name: "Deprecated",
|
||||||
|
description: "Template is incompatible",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
examples: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmptyCanCreate: Story = {
|
||||||
|
args: {
|
||||||
|
canCreateTemplates: true,
|
||||||
|
error: undefined,
|
||||||
|
templatesByOrg: getTemplatesByOrg([]),
|
||||||
|
examples: [MockTemplateExample, MockTemplateExample2],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmptyCannotCreate: Story = {
|
||||||
|
args: {
|
||||||
|
error: undefined,
|
||||||
|
templatesByOrg: getTemplatesByOrg([]),
|
||||||
|
examples: [MockTemplateExample, MockTemplateExample2],
|
||||||
|
canCreateTemplates: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Error: Story = {
|
||||||
|
args: {
|
||||||
|
error: mockApiError({
|
||||||
|
message: "Something went wrong fetching templates.",
|
||||||
|
}),
|
||||||
|
templatesByOrg: undefined,
|
||||||
|
examples: undefined,
|
||||||
|
canCreateTemplates: false,
|
||||||
|
},
|
||||||
|
};
|
@ -0,0 +1,185 @@
|
|||||||
|
import type { Interpolation, Theme } from "@emotion/react";
|
||||||
|
import type { FC } from "react";
|
||||||
|
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
|
import type { TemplateExample } from "api/typesGenerated";
|
||||||
|
import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||||
|
import {
|
||||||
|
HelpTooltip,
|
||||||
|
HelpTooltipContent,
|
||||||
|
HelpTooltipLink,
|
||||||
|
HelpTooltipLinksGroup,
|
||||||
|
HelpTooltipText,
|
||||||
|
HelpTooltipTitle,
|
||||||
|
HelpTooltipTrigger,
|
||||||
|
} from "components/HelpTooltip/HelpTooltip";
|
||||||
|
import { Loader } from "components/Loader/Loader";
|
||||||
|
import { Margins } from "components/Margins/Margins";
|
||||||
|
import {
|
||||||
|
PageHeader,
|
||||||
|
PageHeaderSubtitle,
|
||||||
|
PageHeaderTitle,
|
||||||
|
} from "components/PageHeader/PageHeader";
|
||||||
|
import { Stack } from "components/Stack/Stack";
|
||||||
|
import { TemplateCard } from "modules/templates/TemplateCard/TemplateCard";
|
||||||
|
import { docs } from "utils/docs";
|
||||||
|
import type { TemplatesByOrg } from "utils/templateAggregators";
|
||||||
|
import { CreateTemplateButton } from "../CreateTemplateButton";
|
||||||
|
import { EmptyTemplates } from "../EmptyTemplates";
|
||||||
|
|
||||||
|
export const Language = {
|
||||||
|
templateTooltipTitle: "What is a template?",
|
||||||
|
templateTooltipText:
|
||||||
|
"Templates allow you to create a common configuration for your workspaces using Terraform.",
|
||||||
|
templateTooltipLink: "Manage templates",
|
||||||
|
};
|
||||||
|
|
||||||
|
const TemplateHelpTooltip: FC = () => {
|
||||||
|
return (
|
||||||
|
<HelpTooltip>
|
||||||
|
<HelpTooltipTrigger />
|
||||||
|
<HelpTooltipContent>
|
||||||
|
<HelpTooltipTitle>{Language.templateTooltipTitle}</HelpTooltipTitle>
|
||||||
|
<HelpTooltipText>{Language.templateTooltipText}</HelpTooltipText>
|
||||||
|
<HelpTooltipLinksGroup>
|
||||||
|
<HelpTooltipLink href={docs("/templates")}>
|
||||||
|
{Language.templateTooltipLink}
|
||||||
|
</HelpTooltipLink>
|
||||||
|
</HelpTooltipLinksGroup>
|
||||||
|
</HelpTooltipContent>
|
||||||
|
</HelpTooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TemplatesPageViewProps {
|
||||||
|
templatesByOrg?: TemplatesByOrg;
|
||||||
|
examples: TemplateExample[] | undefined;
|
||||||
|
canCreateTemplates: boolean;
|
||||||
|
error?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TemplatesPageView: FC<TemplatesPageViewProps> = ({
|
||||||
|
templatesByOrg,
|
||||||
|
examples,
|
||||||
|
canCreateTemplates,
|
||||||
|
error,
|
||||||
|
}) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [urlParams] = useSearchParams();
|
||||||
|
const isEmpty = templatesByOrg && templatesByOrg["all"].length === 0;
|
||||||
|
const activeOrg = urlParams.get("org") ?? "all";
|
||||||
|
const visibleTemplates = templatesByOrg
|
||||||
|
? templatesByOrg[activeOrg]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Margins>
|
||||||
|
<PageHeader
|
||||||
|
actions={
|
||||||
|
canCreateTemplates && <CreateTemplateButton onNavigate={navigate} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PageHeaderTitle>
|
||||||
|
<Stack spacing={1} direction="row" alignItems="center">
|
||||||
|
Templates
|
||||||
|
<TemplateHelpTooltip />
|
||||||
|
</Stack>
|
||||||
|
</PageHeaderTitle>
|
||||||
|
{!isEmpty && (
|
||||||
|
<PageHeaderSubtitle>
|
||||||
|
Select a template to create a workspace.
|
||||||
|
</PageHeaderSubtitle>
|
||||||
|
)}
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{Boolean(error) && (
|
||||||
|
<ErrorAlert error={error} css={{ marginBottom: 32 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Boolean(!templatesByOrg) && <Loader />}
|
||||||
|
|
||||||
|
<Stack direction="row" spacing={4} alignItems="flex-start">
|
||||||
|
{templatesByOrg && Object.keys(templatesByOrg).length > 2 && (
|
||||||
|
<Stack
|
||||||
|
css={{ width: 208, flexShrink: 0, position: "sticky", top: 48 }}
|
||||||
|
>
|
||||||
|
<span css={styles.filterCaption}>ORGANIZATION</span>
|
||||||
|
{Object.entries(templatesByOrg).map((org) => (
|
||||||
|
<Link
|
||||||
|
key={org[0]}
|
||||||
|
to={`?org=${org[0]}`}
|
||||||
|
css={[
|
||||||
|
styles.tagLink,
|
||||||
|
org[0] === activeOrg && styles.tagLinkActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{org[0] === "all" ? "all" : org[1][0].organization_display_name}{" "}
|
||||||
|
({org[1].length})
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
css={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 32,
|
||||||
|
height: "max-content",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEmpty ? (
|
||||||
|
<EmptyTemplates
|
||||||
|
canCreateTemplates={canCreateTemplates}
|
||||||
|
examples={examples ?? []}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
visibleTemplates &&
|
||||||
|
visibleTemplates.map((template) => (
|
||||||
|
<TemplateCard
|
||||||
|
css={(theme) => ({
|
||||||
|
backgroundColor: theme.palette.background.paper,
|
||||||
|
})}
|
||||||
|
template={template}
|
||||||
|
key={template.id}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Stack>
|
||||||
|
</Margins>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
filterCaption: (theme) => ({
|
||||||
|
textTransform: "uppercase",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 12,
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
letterSpacing: "0.1em",
|
||||||
|
}),
|
||||||
|
tagLink: (theme) => ({
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
textDecoration: "none",
|
||||||
|
fontSize: 14,
|
||||||
|
textTransform: "capitalize",
|
||||||
|
|
||||||
|
"&:hover": {
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
tagLinkActive: (theme) => ({
|
||||||
|
color: theme.palette.text.primary,
|
||||||
|
fontWeight: 600,
|
||||||
|
}),
|
||||||
|
secondary: (theme) => ({
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
}),
|
||||||
|
actionButton: (theme) => ({
|
||||||
|
transition: "none",
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
"&:hover": {
|
||||||
|
borderColor: theme.palette.text.primary,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} satisfies Record<string, Interpolation<Theme>>;
|
@ -43,8 +43,8 @@ import {
|
|||||||
formatTemplateBuildTime,
|
formatTemplateBuildTime,
|
||||||
formatTemplateActiveDevelopers,
|
formatTemplateActiveDevelopers,
|
||||||
} from "utils/templates";
|
} from "utils/templates";
|
||||||
import { CreateTemplateButton } from "./CreateTemplateButton";
|
import { CreateTemplateButton } from "../CreateTemplateButton";
|
||||||
import { EmptyTemplates } from "./EmptyTemplates";
|
import { EmptyTemplates } from "../EmptyTemplates";
|
||||||
|
|
||||||
export const Language = {
|
export const Language = {
|
||||||
developerCount: (activeCount: number): string => {
|
developerCount: (activeCount: number): string => {
|
@ -1,34 +1,59 @@
|
|||||||
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 { templateExamples, templates } from "api/queries/templates";
|
import {
|
||||||
|
templateExamples,
|
||||||
|
templatesByOrganizationId,
|
||||||
|
templates,
|
||||||
|
} from "api/queries/templates";
|
||||||
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";
|
||||||
import { TemplatesPageView } from "./TemplatesPageView";
|
import { getTemplatesByOrg } from "utils/templateAggregators";
|
||||||
|
import { TemplatesPageView as MultiOrgTemplatesPageView } from "./MultiOrgTemplatePage/TemplatesPageView";
|
||||||
|
import { TemplatesPageView } from "./TemplatePage/TemplatesPageView";
|
||||||
|
|
||||||
export const TemplatesPage: FC = () => {
|
export const TemplatesPage: FC = () => {
|
||||||
const { permissions } = useAuthenticated();
|
const { permissions } = useAuthenticated();
|
||||||
const { organizationId } = useDashboard();
|
const { organizationId, experiments } = useDashboard();
|
||||||
|
|
||||||
const templatesQuery = useQuery(templates(organizationId));
|
const templatesByOrganizationIdQuery = useQuery(
|
||||||
|
templatesByOrganizationId(organizationId),
|
||||||
|
);
|
||||||
|
const templatesQuery = useQuery(templates());
|
||||||
|
const templatesByOrg = templatesQuery.data
|
||||||
|
? getTemplatesByOrg(templatesQuery.data)
|
||||||
|
: undefined;
|
||||||
const examplesQuery = useQuery({
|
const examplesQuery = useQuery({
|
||||||
...templateExamples(organizationId),
|
...templateExamples(organizationId),
|
||||||
enabled: permissions.createTemplates,
|
enabled: permissions.createTemplates,
|
||||||
});
|
});
|
||||||
const error = templatesQuery.error || examplesQuery.error;
|
const error =
|
||||||
|
templatesByOrganizationIdQuery.error ||
|
||||||
|
examplesQuery.error ||
|
||||||
|
templatesQuery.error;
|
||||||
|
const multiOrgExperimentEnabled = experiments.includes("multi-organization");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{pageTitle("Templates")}</title>
|
<title>{pageTitle("Templates")}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<TemplatesPageView
|
{multiOrgExperimentEnabled ? (
|
||||||
error={error}
|
<MultiOrgTemplatesPageView
|
||||||
canCreateTemplates={permissions.createTemplates}
|
templatesByOrg={templatesByOrg}
|
||||||
examples={examplesQuery.data}
|
examples={examplesQuery.data}
|
||||||
templates={templatesQuery.data}
|
canCreateTemplates={permissions.createTemplates}
|
||||||
/>
|
error={error}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TemplatesPageView
|
||||||
|
error={error}
|
||||||
|
canCreateTemplates={permissions.createTemplates}
|
||||||
|
examples={examplesQuery.data}
|
||||||
|
templates={templatesByOrganizationIdQuery.data}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -2,7 +2,7 @@ import { type FC, useEffect, useState } 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 { useSearchParams } from "react-router-dom";
|
||||||
import { templates } from "api/queries/templates";
|
import { templatesByOrganizationId } from "api/queries/templates";
|
||||||
import type { Workspace } from "api/typesGenerated";
|
import type { Workspace } from "api/typesGenerated";
|
||||||
import { useFilter } from "components/Filter/filter";
|
import { useFilter } from "components/Filter/filter";
|
||||||
import { useUserFilterMenu } from "components/Filter/UserFilter";
|
import { useUserFilterMenu } from "components/Filter/UserFilter";
|
||||||
@ -41,7 +41,9 @@ const WorkspacesPage: FC = () => {
|
|||||||
const { permissions } = useAuthenticated();
|
const { permissions } = useAuthenticated();
|
||||||
const { entitlements, organizationId } = useDashboard();
|
const { entitlements, organizationId } = useDashboard();
|
||||||
|
|
||||||
const templatesQuery = useQuery(templates(organizationId, false));
|
const templatesQuery = useQuery(
|
||||||
|
templatesByOrganizationId(organizationId, false),
|
||||||
|
);
|
||||||
|
|
||||||
const filterProps = useWorkspacesFilter({
|
const filterProps = useWorkspacesFilter({
|
||||||
searchParamsResult,
|
searchParamsResult,
|
||||||
|
@ -27,7 +27,7 @@ export const useTemplateFilterMenu = ({
|
|||||||
id: "template",
|
id: "template",
|
||||||
getSelectedOption: async () => {
|
getSelectedOption: async () => {
|
||||||
// Show all templates including deprecated
|
// Show all templates including deprecated
|
||||||
const templates = await API.getTemplates(organizationId);
|
const templates = await API.getTemplatesByOrganizationId(organizationId);
|
||||||
const template = templates.find((template) => template.name === value);
|
const template = templates.find((template) => template.name === value);
|
||||||
if (template) {
|
if (template) {
|
||||||
return {
|
return {
|
||||||
@ -40,7 +40,7 @@ export const useTemplateFilterMenu = ({
|
|||||||
},
|
},
|
||||||
getOptions: async (query) => {
|
getOptions: async (query) => {
|
||||||
// Show all templates including deprecated
|
// Show all templates including deprecated
|
||||||
const templates = await API.getTemplates(organizationId);
|
const templates = await API.getTemplatesByOrganizationId(organizationId);
|
||||||
const filteredTemplates = templates.filter(
|
const filteredTemplates = templates.filter(
|
||||||
(template) =>
|
(template) =>
|
||||||
template.name.toLowerCase().includes(query.toLowerCase()) ||
|
template.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
|
@ -4,3 +4,4 @@ export function prepareQuery(query: string | undefined): string | undefined;
|
|||||||
export function prepareQuery(query?: string): string | undefined {
|
export function prepareQuery(query?: string): string | undefined {
|
||||||
return query?.trim().replace(/ +/g, " ");
|
return query?.trim().replace(/ +/g, " ");
|
||||||
}
|
}
|
||||||
|
export const filterParamsKey = "filter";
|
||||||
|
@ -1,24 +0,0 @@
|
|||||||
import type { TemplateExample } from "api/typesGenerated";
|
|
||||||
|
|
||||||
export type StarterTemplatesByTag = Record<string, TemplateExample[]>;
|
|
||||||
|
|
||||||
export const getTemplatesByTag = (
|
|
||||||
templates: TemplateExample[],
|
|
||||||
): StarterTemplatesByTag => {
|
|
||||||
const tags: StarterTemplatesByTag = {
|
|
||||||
all: templates,
|
|
||||||
};
|
|
||||||
|
|
||||||
templates.forEach((template) => {
|
|
||||||
template.tags.forEach((tag) => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- this can be undefined
|
|
||||||
if (tags[tag]) {
|
|
||||||
tags[tag].push(template);
|
|
||||||
} else {
|
|
||||||
tags[tag] = [template];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return tags;
|
|
||||||
};
|
|
46
site/src/utils/templateAggregators.ts
Normal file
46
site/src/utils/templateAggregators.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import type { Template, TemplateExample } from "api/typesGenerated";
|
||||||
|
|
||||||
|
export type StarterTemplatesByTag = Record<string, TemplateExample[]>;
|
||||||
|
export type TemplatesByOrg = Record<string, Template[]>;
|
||||||
|
|
||||||
|
export const getTemplatesByTag = (
|
||||||
|
templates: TemplateExample[],
|
||||||
|
): StarterTemplatesByTag => {
|
||||||
|
const tags: StarterTemplatesByTag = {
|
||||||
|
all: templates,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const template of templates) {
|
||||||
|
for (const tag of template.tags) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- this can be undefined
|
||||||
|
if (tags[tag]) {
|
||||||
|
tags[tag].push(template);
|
||||||
|
} else {
|
||||||
|
tags[tag] = [template];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTemplatesByOrg = (templates: Template[]): TemplatesByOrg => {
|
||||||
|
const orgs: TemplatesByOrg = {};
|
||||||
|
|
||||||
|
for (const template of templates) {
|
||||||
|
const org = template.organization_id;
|
||||||
|
if (orgs[org]) {
|
||||||
|
orgs[org].push(template);
|
||||||
|
} else {
|
||||||
|
orgs[org] = [template];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedOrgs = Object.fromEntries(
|
||||||
|
Object.entries(orgs).sort(([, a], [, b]) =>
|
||||||
|
a[0].organization_name.localeCompare(b[0].organization_name),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { all: templates, ...sortedOrgs };
|
||||||
|
};
|
Reference in New Issue
Block a user