From 554c4ab1eb29c3c61bbee16c94a836a48bd61769 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 19 Jul 2024 10:33:08 -0400 Subject: [PATCH] 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 --- codersdk/organizations.go | 10 +- site/src/api/api.ts | 10 +- site/src/api/queries/audits.ts | 4 +- site/src/api/queries/templates.ts | 32 ++- site/src/api/typesGenerated.ts | 3 +- site/src/components/Filter/filter.tsx | 7 +- .../TemplateCard/TemplateCard.stories.tsx | 40 ++++ .../templates/TemplateCard/TemplateCard.tsx | 144 ++++++++++++++ .../StarterTemplatesPage.tsx | 2 +- .../StarterTemplatesPageView.stories.tsx | 2 +- .../StarterTemplatesPageView.tsx | 2 +- .../TemplatesPageView.stories.tsx | 154 +++++++++++++++ .../TemplatesPageView.tsx | 185 ++++++++++++++++++ .../TemplatesPageView.stories.tsx | 0 .../{ => TemplatePage}/TemplatesPageView.tsx | 4 +- .../src/pages/TemplatesPage/TemplatesPage.tsx | 47 +++-- .../pages/WorkspacesPage/WorkspacesPage.tsx | 6 +- .../src/pages/WorkspacesPage/filter/menus.tsx | 4 +- site/src/utils/filters.ts | 1 + site/src/utils/starterTemplates.ts | 24 --- site/src/utils/templateAggregators.ts | 46 +++++ 21 files changed, 663 insertions(+), 64 deletions(-) create mode 100644 site/src/modules/templates/TemplateCard/TemplateCard.stories.tsx create mode 100644 site/src/modules/templates/TemplateCard/TemplateCard.tsx create mode 100644 site/src/pages/TemplatesPage/MultiOrgTemplatePage/TemplatesPageView.stories.tsx create mode 100644 site/src/pages/TemplatesPage/MultiOrgTemplatePage/TemplatesPageView.tsx rename site/src/pages/TemplatesPage/{ => TemplatePage}/TemplatesPageView.stories.tsx (100%) rename site/src/pages/TemplatesPage/{ => TemplatePage}/TemplatesPageView.tsx (98%) delete mode 100644 site/src/utils/starterTemplates.ts create mode 100644 site/src/utils/templateAggregators.ts diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 758db099f9..b1b5933781 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -400,8 +400,9 @@ func (c *Client) TemplatesByOrganization(ctx context.Context, organizationID uui } type TemplateFilter struct { - OrganizationID uuid.UUID - ExactName string + OrganizationID uuid.UUID `json:"organization_id,omitempty" format:"uuid" typescript:"-"` + FilterQuery string `json:"q,omitempty"` + ExactName string `json:"exact_name,omitempty" typescript:"-"` } // 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)) } + if f.FilterQuery != "" { + // If custom stuff is added, just add it on here. + params = append(params, f.FilterQuery) + } + q := r.URL.Query() q.Set("q", strings.Join(params, " ")) r.URL.RawQuery = q.Encode() diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 72d3ea2e48..40627fe472 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -578,7 +578,7 @@ class ApiMethods { return response.data; }; - getTemplates = async ( + getTemplatesByOrganizationId = async ( organizationId: string, options?: TemplateOptions, ): Promise => { @@ -598,6 +598,14 @@ class ApiMethods { return response.data; }; + getTemplates = async ( + options?: TypesGen.TemplateFilter, + ): Promise => { + const url = getURLWithSearchParams("/api/v2/templates", options); + const response = await this.axios.get(url); + return response.data; + }; + getTemplateByName = async ( organizationId: string, name: string, diff --git a/site/src/api/queries/audits.ts b/site/src/api/queries/audits.ts index 1dce9a29ea..dbdfea48ff 100644 --- a/site/src/api/queries/audits.ts +++ b/site/src/api/queries/audits.ts @@ -1,14 +1,14 @@ import { API } from "api/api"; import type { AuditLogResponse } from "api/typesGenerated"; -import { useFilterParamsKey } from "components/Filter/filter"; import type { UsePaginatedQueryOptions } from "hooks/usePaginatedQuery"; +import { filterParamsKey } from "utils/filters"; export function paginatedAudits( searchParams: URLSearchParams, ): UsePaginatedQueryOptions { return { searchParams, - queryPayload: () => searchParams.get(useFilterParamsKey) ?? "", + queryPayload: () => searchParams.get(filterParamsKey) ?? "", queryKey: ({ payload, pageNumber }) => { return ["auditLogs", payload, pageNumber] as const; }, diff --git a/site/src/api/queries/templates.ts b/site/src/api/queries/templates.ts index 2d0485b8f3..312e626949 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -1,6 +1,7 @@ import type { MutationOptions, QueryClient, QueryOptions } from "react-query"; import { API } from "api/api"; import type { + TemplateFilter, CreateTemplateRequest, CreateTemplateVersionRequest, ProvisionerJob, @@ -30,16 +31,26 @@ export const templateByName = ( }; }; -const getTemplatesQueryKey = (organizationId: string, deprecated?: boolean) => [ - organizationId, - "templates", - deprecated, -]; +const getTemplatesByOrganizationIdQueryKey = ( + organizationId: string, + deprecated?: boolean, +) => [organizationId, "templates", deprecated]; -export const templates = (organizationId: string, deprecated?: boolean) => { +export const templatesByOrganizationId = ( + organizationId: string, + deprecated?: boolean, +) => { return { - queryKey: getTemplatesQueryKey(organizationId, deprecated), - queryFn: () => API.getTemplates(organizationId, { deprecated }), + queryKey: getTemplatesByOrganizationIdQueryKey(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) => { return { - queryKey: [...getTemplatesQueryKey(organizationId), "examples"], + queryKey: [ + ...getTemplatesByOrganizationIdQueryKey(organizationId), + "examples", + ], queryFn: () => API.getTemplateExamples(organizationId), }; }; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 8b952ff609..b6ffcf1c79 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1246,8 +1246,7 @@ export interface TemplateExample { // From codersdk/organizations.go export interface TemplateFilter { - readonly OrganizationID: string; - readonly ExactName: string; + readonly q?: string; } // From codersdk/templates.go diff --git a/site/src/components/Filter/filter.tsx b/site/src/components/Filter/filter.tsx index b26ce444a8..f37510dbd2 100644 --- a/site/src/components/Filter/filter.tsx +++ b/site/src/components/Filter/filter.tsx @@ -16,6 +16,7 @@ import { import { InputGroup } from "components/InputGroup/InputGroup"; import { SearchField } from "components/SearchField/SearchField"; import { useDebouncedFunction } from "hooks/debounce"; +import { filterParamsKey } from "utils/filters"; export type PresetFilter = { name: string; @@ -35,21 +36,19 @@ type UseFilterConfig = { onUpdate?: (newValue: string) => void; }; -export const useFilterParamsKey = "filter"; - export const useFilter = ({ fallbackFilter = "", searchParamsResult, onUpdate, }: UseFilterConfig) => { const [searchParams, setSearchParams] = searchParamsResult; - const query = searchParams.get(useFilterParamsKey) ?? fallbackFilter; + const query = searchParams.get(filterParamsKey) ?? fallbackFilter; const update = (newValues: string | FilterValues) => { const serialized = typeof newValues === "string" ? newValues : stringifyFilter(newValues); - searchParams.set(useFilterParamsKey, serialized); + searchParams.set(filterParamsKey, serialized); setSearchParams(searchParams); if (onUpdate !== undefined) { diff --git a/site/src/modules/templates/TemplateCard/TemplateCard.stories.tsx b/site/src/modules/templates/TemplateCard/TemplateCard.stories.tsx new file mode 100644 index 0000000000..863b7a9a2b --- /dev/null +++ b/site/src/modules/templates/TemplateCard/TemplateCard.stories.tsx @@ -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 = { + title: "modules/templates/TemplateCard", + parameters: { chromatic }, + component: TemplateCard, + args: { + template: MockTemplate, + }, +}; + +export default meta; +type Story = StoryObj; + +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, + }, + }, +}; diff --git a/site/src/modules/templates/TemplateCard/TemplateCard.tsx b/site/src/modules/templates/TemplateCard/TemplateCard.tsx new file mode 100644 index 0000000000..aa4a6bcf45 --- /dev/null +++ b/site/src/modules/templates/TemplateCard/TemplateCard.tsx @@ -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 & { + template: Template; +}; + +export const TemplateCard: FC = ({ + 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 ( +
navigate(templatePageLink)} + onKeyDown={handleKeyDown} + > +
+
+ 0 + ? template.display_name + : template.name + } + subtitle={template.organization_display_name} + avatar={ + hasIcon && ( + + ) + } + /> +
+
+ {template.active_user_count}{" "} + {template.active_user_count === 1 ? "user" : "users"} +
+
+ +
+ +

{template.description}

+
+
+ +
+ {template.deprecated ? ( + + ) : ( + + )} +
+
+ ); +}; + +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>; diff --git a/site/src/pages/StarterTemplatesPage/StarterTemplatesPage.tsx b/site/src/pages/StarterTemplatesPage/StarterTemplatesPage.tsx index d52c92a12d..0e524e6774 100644 --- a/site/src/pages/StarterTemplatesPage/StarterTemplatesPage.tsx +++ b/site/src/pages/StarterTemplatesPage/StarterTemplatesPage.tsx @@ -5,7 +5,7 @@ import { templateExamples } from "api/queries/templates"; import type { TemplateExample } from "api/typesGenerated"; import { useDashboard } from "modules/dashboard/useDashboard"; import { pageTitle } from "utils/page"; -import { getTemplatesByTag } from "utils/starterTemplates"; +import { getTemplatesByTag } from "utils/templateAggregators"; import { StarterTemplatesPageView } from "./StarterTemplatesPageView"; const StarterTemplatesPage: FC = () => { diff --git a/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.stories.tsx b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.stories.tsx index 228e8cae4e..c2bb6a11f3 100644 --- a/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.stories.tsx +++ b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.stories.tsx @@ -5,7 +5,7 @@ import { MockTemplateExample, MockTemplateExample2, } from "testHelpers/entities"; -import { getTemplatesByTag } from "utils/starterTemplates"; +import { getTemplatesByTag } from "utils/templateAggregators"; import { StarterTemplatesPageView } from "./StarterTemplatesPageView"; const meta: Meta = { diff --git a/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx index e0a6c4b975..9d32a069cb 100644 --- a/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx +++ b/site/src/pages/StarterTemplatesPage/StarterTemplatesPageView.tsx @@ -11,7 +11,7 @@ import { } from "components/PageHeader/PageHeader"; import { Stack } from "components/Stack/Stack"; import { TemplateExampleCard } from "modules/templates/TemplateExampleCard/TemplateExampleCard"; -import type { StarterTemplatesByTag } from "utils/starterTemplates"; +import type { StarterTemplatesByTag } from "utils/templateAggregators"; const getTagLabel = (tag: string) => { const labelByTag: Record = { diff --git a/site/src/pages/TemplatesPage/MultiOrgTemplatePage/TemplatesPageView.stories.tsx b/site/src/pages/TemplatesPage/MultiOrgTemplatePage/TemplatesPageView.stories.tsx new file mode 100644 index 0000000000..10eacf0ae6 --- /dev/null +++ b/site/src/pages/TemplatesPage/MultiOrgTemplatePage/TemplatesPageView.stories.tsx @@ -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 = { + title: "pages/MultiOrgTemplatesPage", + parameters: { chromatic: chromaticWithTablet }, + component: TemplatesPageView, +}; + +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/site/src/pages/TemplatesPage/MultiOrgTemplatePage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/MultiOrgTemplatePage/TemplatesPageView.tsx new file mode 100644 index 0000000000..095930fa16 --- /dev/null +++ b/site/src/pages/TemplatesPage/MultiOrgTemplatePage/TemplatesPageView.tsx @@ -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 ( + + + + {Language.templateTooltipTitle} + {Language.templateTooltipText} + + + {Language.templateTooltipLink} + + + + + ); +}; + +export interface TemplatesPageViewProps { + templatesByOrg?: TemplatesByOrg; + examples: TemplateExample[] | undefined; + canCreateTemplates: boolean; + error?: unknown; +} + +export const TemplatesPageView: FC = ({ + 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 ( + + + } + > + + + Templates + + + + {!isEmpty && ( + + Select a template to create a workspace. + + )} + + + {Boolean(error) && ( + + )} + + {Boolean(!templatesByOrg) && } + + + {templatesByOrg && Object.keys(templatesByOrg).length > 2 && ( + + ORGANIZATION + {Object.entries(templatesByOrg).map((org) => ( + + {org[0] === "all" ? "all" : org[1][0].organization_display_name}{" "} + ({org[1].length}) + + ))} + + )} + +
+ {isEmpty ? ( + + ) : ( + visibleTemplates && + visibleTemplates.map((template) => ( + ({ + backgroundColor: theme.palette.background.paper, + })} + template={template} + key={template.id} + /> + )) + )} +
+
+
+ ); +}; + +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>; diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx b/site/src/pages/TemplatesPage/TemplatePage/TemplatesPageView.stories.tsx similarity index 100% rename from site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx rename to site/src/pages/TemplatesPage/TemplatePage/TemplatesPageView.stories.tsx diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatePage/TemplatesPageView.tsx similarity index 98% rename from site/src/pages/TemplatesPage/TemplatesPageView.tsx rename to site/src/pages/TemplatesPage/TemplatePage/TemplatesPageView.tsx index fd7be676da..7cf4d968f8 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatePage/TemplatesPageView.tsx @@ -43,8 +43,8 @@ import { formatTemplateBuildTime, formatTemplateActiveDevelopers, } from "utils/templates"; -import { CreateTemplateButton } from "./CreateTemplateButton"; -import { EmptyTemplates } from "./EmptyTemplates"; +import { CreateTemplateButton } from "../CreateTemplateButton"; +import { EmptyTemplates } from "../EmptyTemplates"; export const Language = { developerCount: (activeCount: number): string => { diff --git a/site/src/pages/TemplatesPage/TemplatesPage.tsx b/site/src/pages/TemplatesPage/TemplatesPage.tsx index 75c98d5221..d8b60562f7 100644 --- a/site/src/pages/TemplatesPage/TemplatesPage.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPage.tsx @@ -1,34 +1,59 @@ import type { FC } from "react"; import { Helmet } from "react-helmet-async"; 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 { useDashboard } from "modules/dashboard/useDashboard"; 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 = () => { 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({ ...templateExamples(organizationId), enabled: permissions.createTemplates, }); - const error = templatesQuery.error || examplesQuery.error; + const error = + templatesByOrganizationIdQuery.error || + examplesQuery.error || + templatesQuery.error; + const multiOrgExperimentEnabled = experiments.includes("multi-organization"); return ( <> {pageTitle("Templates")} - + {multiOrgExperimentEnabled ? ( + + ) : ( + + )} ); }; diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 277716f6a9..944e32580a 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -2,7 +2,7 @@ import { type FC, useEffect, useState } from "react"; import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; 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 { useFilter } from "components/Filter/filter"; import { useUserFilterMenu } from "components/Filter/UserFilter"; @@ -41,7 +41,9 @@ const WorkspacesPage: FC = () => { const { permissions } = useAuthenticated(); const { entitlements, organizationId } = useDashboard(); - const templatesQuery = useQuery(templates(organizationId, false)); + const templatesQuery = useQuery( + templatesByOrganizationId(organizationId, false), + ); const filterProps = useWorkspacesFilter({ searchParamsResult, diff --git a/site/src/pages/WorkspacesPage/filter/menus.tsx b/site/src/pages/WorkspacesPage/filter/menus.tsx index 0316f158e8..1ef95002ab 100644 --- a/site/src/pages/WorkspacesPage/filter/menus.tsx +++ b/site/src/pages/WorkspacesPage/filter/menus.tsx @@ -27,7 +27,7 @@ export const useTemplateFilterMenu = ({ id: "template", getSelectedOption: async () => { // 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); if (template) { return { @@ -40,7 +40,7 @@ export const useTemplateFilterMenu = ({ }, getOptions: async (query) => { // Show all templates including deprecated - const templates = await API.getTemplates(organizationId); + const templates = await API.getTemplatesByOrganizationId(organizationId); const filteredTemplates = templates.filter( (template) => template.name.toLowerCase().includes(query.toLowerCase()) || diff --git a/site/src/utils/filters.ts b/site/src/utils/filters.ts index 164ef633b5..4ccd1cb398 100644 --- a/site/src/utils/filters.ts +++ b/site/src/utils/filters.ts @@ -4,3 +4,4 @@ export function prepareQuery(query: string | undefined): string | undefined; export function prepareQuery(query?: string): string | undefined { return query?.trim().replace(/ +/g, " "); } +export const filterParamsKey = "filter"; diff --git a/site/src/utils/starterTemplates.ts b/site/src/utils/starterTemplates.ts deleted file mode 100644 index edbc690eba..0000000000 --- a/site/src/utils/starterTemplates.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { TemplateExample } from "api/typesGenerated"; - -export type StarterTemplatesByTag = Record; - -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; -}; diff --git a/site/src/utils/templateAggregators.ts b/site/src/utils/templateAggregators.ts new file mode 100644 index 0000000000..93f368263b --- /dev/null +++ b/site/src/utils/templateAggregators.ts @@ -0,0 +1,46 @@ +import type { Template, TemplateExample } from "api/typesGenerated"; + +export type StarterTemplatesByTag = Record; +export type TemplatesByOrg = Record; + +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 }; +};