From bcc66367ae089620db0f54c83bbcad913e248aef Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 9 Aug 2024 17:04:19 +0000 Subject: [PATCH 1/8] feat: show organization information on templates page --- .../modules/dashboard/DashboardProvider.tsx | 8 ++++ site/src/modules/navigation.ts | 11 +---- .../ManagementSettingsLayout.tsx | 2 +- .../src/pages/TemplatesPage/TemplatesPage.tsx | 3 ++ .../pages/TemplatesPage/TemplatesPageView.tsx | 48 ++++++++++++++++--- site/src/testHelpers/storybook.tsx | 1 + 6 files changed, 56 insertions(+), 17 deletions(-) diff --git a/site/src/modules/dashboard/DashboardProvider.tsx b/site/src/modules/dashboard/DashboardProvider.tsx index f8cf6eca1a1ba..0a6ede104747c 100644 --- a/site/src/modules/dashboard/DashboardProvider.tsx +++ b/site/src/modules/dashboard/DashboardProvider.tsx @@ -13,12 +13,14 @@ import type { import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Loader } from "components/Loader/Loader"; import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata"; +import { selectFeatureVisibility } from "./entitlements"; export interface DashboardValue { entitlements: Entitlements; experiments: Experiments; appearance: AppearanceConfig; organizations: Organization[]; + showOrganizations: boolean; } export const DashboardContext = createContext( @@ -52,6 +54,11 @@ export const DashboardProvider: FC = ({ children }) => { return ; } + const hasMultipleOrganizations = organizationsQuery.data.length > 1; + const organizationsEnabled = + experimentsQuery.data.includes("multi-organization") && + selectFeatureVisibility(entitlementsQuery.data).multiple_organizations; + return ( = ({ children }) => { experiments: experimentsQuery.data, appearance: appearanceQuery.data, organizations: organizationsQuery.data, + showOrganizations: hasMultipleOrganizations || organizationsEnabled, }} > {children} diff --git a/site/src/modules/navigation.ts b/site/src/modules/navigation.ts index d319d5972d1ea..9d6773b73e4f6 100644 --- a/site/src/modules/navigation.ts +++ b/site/src/modules/navigation.ts @@ -4,7 +4,6 @@ import { useEffectEvent } from "hooks/hookPolyfills"; import type { DashboardValue } from "./dashboard/DashboardProvider"; -import { selectFeatureVisibility } from "./dashboard/entitlements"; import { useDashboard } from "./dashboard/useDashboard"; type LinkThunk = (state: DashboardValue) => string; @@ -27,13 +26,7 @@ export const linkToUsers = withFilter("/users", "status:active"); export const linkToTemplate = (organizationName: string, templateName: string): LinkThunk => - (dashboard) => { - const hasMultipleOrganizations = dashboard.organizations.length > 1; - const organizationsEnabled = - dashboard.experiments.includes("multi-organization") && - selectFeatureVisibility(dashboard.entitlements).multiple_organizations; - - return hasMultipleOrganizations || organizationsEnabled + (dashboard) => + dashboard.showOrganizations ? `/templates/${organizationName}/${templateName}` : `/templates/${templateName}`; - }; diff --git a/site/src/pages/ManagementSettingsPage/ManagementSettingsLayout.tsx b/site/src/pages/ManagementSettingsPage/ManagementSettingsLayout.tsx index c31bbfe2b54c7..283b5a714560a 100644 --- a/site/src/pages/ManagementSettingsPage/ManagementSettingsLayout.tsx +++ b/site/src/pages/ManagementSettingsPage/ManagementSettingsLayout.tsx @@ -13,7 +13,7 @@ import { DeploySettingsContext } from "../DeploySettingsPage/DeploySettingsLayou import { Sidebar } from "./Sidebar"; type OrganizationSettingsValue = { - organizations: Organization[] | undefined; + organizations: Organization[]; }; export const useOrganizationSettings = (): OrganizationSettingsValue => { diff --git a/site/src/pages/TemplatesPage/TemplatesPage.tsx b/site/src/pages/TemplatesPage/TemplatesPage.tsx index fa9c5bb167669..9e7b0b1e48e2b 100644 --- a/site/src/pages/TemplatesPage/TemplatesPage.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPage.tsx @@ -5,9 +5,11 @@ import { templateExamples, templates } from "api/queries/templates"; import { useAuthenticated } from "contexts/auth/RequireAuth"; import { pageTitle } from "utils/page"; import { TemplatesPageView } from "./TemplatesPageView"; +import { useDashboard } from "modules/dashboard/useDashboard"; export const TemplatesPage: FC = () => { const { permissions } = useAuthenticated(); + const { showOrganizations } = useDashboard(); const templatesQuery = useQuery(templates()); const examplesQuery = useQuery({ @@ -23,6 +25,7 @@ export const TemplatesPage: FC = () => { { }; interface TemplateRowProps { + showOrganizations: boolean; template: Template; } -const TemplateRow: FC = ({ template }) => { +const TemplateRow: FC = ({ showOrganizations, template }) => { const getLink = useLinks(); const templatePageLink = getLink( linkToTemplate(template.organization_name, template.name), @@ -120,7 +121,23 @@ const TemplateRow: FC = ({ template }) => { - {Language.developerCount(template.active_user_count)} + {showOrganizations ? ( + + + {template.organization_display_name} + + + Used by {Language.developerCount(template.active_user_count)} + + + ) : ( + Language.developerCount(template.active_user_count) + )} @@ -156,16 +173,18 @@ const TemplateRow: FC = ({ template }) => { export interface TemplatesPageViewProps { error?: unknown; + showOrganizations: boolean; + canCreateTemplates: boolean; examples: TemplateExample[] | undefined; templates: Template[] | undefined; - canCreateTemplates: boolean; } export const TemplatesPageView: FC = ({ - templates, error, - examples, + showOrganizations, canCreateTemplates, + examples, + templates, }) => { const isLoading = !templates; const isEmpty = templates && templates.length === 0; @@ -209,7 +228,9 @@ export const TemplatesPageView: FC = ({ {Language.nameLabel} - {Language.usedByLabel} + + {showOrganizations ? "Organization" : Language.usedByLabel} + {Language.buildTimeLabel} {Language.lastUpdatedLabel} @@ -225,7 +246,11 @@ export const TemplatesPageView: FC = ({ /> ) : ( templates?.map((template) => ( - + )) )} @@ -276,6 +301,15 @@ const styles = { actionCell: { whiteSpace: "nowrap", }, + cellPrimaryLine: (theme) => ({ + color: theme.palette.text.primary, + fontWeight: 600, + }), + cellSecondaryLine: (theme) => ({ + fontSize: 13, + color: theme.palette.text.secondary, + lineHeight: "150%", + }), secondary: (theme) => ({ color: theme.palette.text.secondary, }), diff --git a/site/src/testHelpers/storybook.tsx b/site/src/testHelpers/storybook.tsx index 30fbc28d0fccc..2cb8036f87a98 100644 --- a/site/src/testHelpers/storybook.tsx +++ b/site/src/testHelpers/storybook.tsx @@ -34,6 +34,7 @@ export const withDashboardProvider = ( experiments, appearance: MockAppearanceConfig, organizations: [MockDefaultOrganization], + showOrganizations: false, }} > From 4fc07cbed2e4047ce4278424372620fbfa686692 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 9 Aug 2024 17:07:20 +0000 Subject: [PATCH 2/8] add story --- site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx index 6267f899df651..63707e67a3c0f 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx @@ -69,6 +69,13 @@ export const WithTemplates: Story = { }, }; +export const MultipleOrganizations: Story = { + args: { + ...WithTemplates.args, + showOrganizations: true, + }, +}; + export const EmptyCanCreate: Story = { args: { canCreateTemplates: true, From 54901e38d0210120b5dfb31d522c64d95b514bc1 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 9 Aug 2024 17:09:09 +0000 Subject: [PATCH 3/8] =?UTF-8?q?=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- site/src/pages/TemplatesPage/TemplatesPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/pages/TemplatesPage/TemplatesPage.tsx b/site/src/pages/TemplatesPage/TemplatesPage.tsx index 9e7b0b1e48e2b..0002198bc9acb 100644 --- a/site/src/pages/TemplatesPage/TemplatesPage.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPage.tsx @@ -3,9 +3,9 @@ import { Helmet } from "react-helmet-async"; import { useQuery } from "react-query"; import { templateExamples, 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 { useDashboard } from "modules/dashboard/useDashboard"; export const TemplatesPage: FC = () => { const { permissions } = useAuthenticated(); From 4b9d029f6cfb4eb287b9444f1cec38bd5393f847 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 9 Aug 2024 20:08:18 +0000 Subject: [PATCH 4/8] chore: enforce correct template page routes --- .../TemplateRedirectController.tsx | 53 +++++++++++++++++++ site/src/router.tsx | 41 +++++++------- 2 files changed, 75 insertions(+), 19 deletions(-) create mode 100644 site/src/pages/TemplatePage/TemplateRedirectController.tsx diff --git a/site/src/pages/TemplatePage/TemplateRedirectController.tsx b/site/src/pages/TemplatePage/TemplateRedirectController.tsx new file mode 100644 index 0000000000000..66da3b6ea0bab --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateRedirectController.tsx @@ -0,0 +1,53 @@ +import type { FC } from "react"; +import { Navigate, Outlet, useLocation, useParams } from "react-router-dom"; +import type { Organization } from "api/typesGenerated"; +import { useDashboard } from "modules/dashboard/useDashboard"; + +export const TemplateRedirectController: FC = () => { + const { organizations, showOrganizations } = useDashboard(); + const { organization, template } = useParams() as { + organization?: string; + template: string; + }; + const location = useLocation(); + + // We redirect templates without an organization to the default organization, + // as that's likely what any links floating around expect. + if (showOrganizations && !organization) { + const extraPath = removePrefix(location.pathname, `/templates/${template}`); + + return ( + + ); + } + + // `showOrganizations` can only be false when there is a single organization, + // so it's safe to throw away the organization name. + if (!showOrganizations && organization) { + const extraPath = removePrefix( + location.pathname, + `/templates/${organization}/${template}`, + ); + + return ( + + ); + } + + return ; +}; + +const getOrganizationNameByDefault = (organizations: Organization[]) => + organizations.find((org) => org.is_default)?.name; + +// I really hate doing it this way, but React Router does not provide a better way. +const removePrefix = (self: string, prefix: string) => + self.startsWith(prefix) ? self.slice(prefix.length) : self; diff --git a/site/src/router.tsx b/site/src/router.tsx index 7f68576ed1911..82d0ec27afa4c 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -1,4 +1,4 @@ -import { Suspense, lazy } from "react"; +import { lazy, Suspense } from "react"; import { createBrowserRouter, createRoutesFromChildren, @@ -23,6 +23,7 @@ import { UsersLayout } from "./pages/UsersPage/UsersLayout"; import UsersPage from "./pages/UsersPage/UsersPage"; import { WorkspaceSettingsLayout } from "./pages/WorkspaceSettingsPage/WorkspaceSettingsLayout"; import WorkspacesPage from "./pages/WorkspacesPage/WorkspacesPage"; +import { TemplateRedirectController } from "pages/TemplatePage/TemplateRedirectController"; // Lazy load pages // - Pages that are secondary, not in the main navigation or not usually accessed @@ -282,27 +283,29 @@ const RoutesWithSuspense = () => { const templateRouter = () => { return ( - }> - } /> - } /> - } /> - } /> - } /> - } /> - + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + - } /> + } /> - }> - } /> - } /> - } /> - } /> - + }> + } /> + } /> + } /> + } /> + - - - } /> + + + } /> + From ff0101baf02c635d7504f1d8323ac4e43d7fdb99 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 9 Aug 2024 20:45:31 +0000 Subject: [PATCH 5/8] well ok then --- site/src/router.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/router.tsx b/site/src/router.tsx index 82d0ec27afa4c..19ee3c24a78ec 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -6,6 +6,7 @@ import { Outlet, Route, } from "react-router-dom"; +import { TemplateRedirectController } from "pages/TemplatePage/TemplateRedirectController"; import { Loader } from "./components/Loader/Loader"; import { RequireAuth } from "./contexts/auth/RequireAuth"; import { DashboardLayout } from "./modules/dashboard/DashboardLayout"; @@ -23,7 +24,6 @@ import { UsersLayout } from "./pages/UsersPage/UsersLayout"; import UsersPage from "./pages/UsersPage/UsersPage"; import { WorkspaceSettingsLayout } from "./pages/WorkspaceSettingsPage/WorkspaceSettingsLayout"; import WorkspacesPage from "./pages/WorkspacesPage/WorkspacesPage"; -import { TemplateRedirectController } from "pages/TemplatePage/TemplateRedirectController"; // Lazy load pages // - Pages that are secondary, not in the main navigation or not usually accessed From 132bc79efda27e91678c184801c8c592dda0d8b8 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Mon, 12 Aug 2024 18:48:41 +0000 Subject: [PATCH 6/8] test! --- .../TemplateRedirectController.test.tsx | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 site/src/pages/TemplatePage/TemplateRedirectController.test.tsx diff --git a/site/src/pages/TemplatePage/TemplateRedirectController.test.tsx b/site/src/pages/TemplatePage/TemplateRedirectController.test.tsx new file mode 100644 index 0000000000000..f90c56f796288 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateRedirectController.test.tsx @@ -0,0 +1,40 @@ +import { API } from "api/api"; +import * as M from "testHelpers/entities"; +import { TemplateRedirectController } from "./TemplateRedirectController"; +import { renderWithAuth } from "testHelpers/renderHelpers"; +import { waitFor } from "@testing-library/react"; + +const renderTemplateRedirectController = (route: string) => { + return renderWithAuth(, { + route, + path: "/templates/:organization?/:template", + }); +}; + +it("redirects from multi-org to single-org", async () => { + const page = renderTemplateRedirectController( + `/templates/${M.MockTemplate.organization_name}/${M.MockTemplate.name}`, + ); + + await waitFor(() => + expect(page.router.state.location.pathname).toEqual( + `/templates/${M.MockTemplate.name}`, + ), + ); +}); + +it("redirects from single-org to multi-org", async () => { + jest + .spyOn(API, "getOrganizations") + .mockResolvedValueOnce([M.MockDefaultOrganization, M.MockOrganization2]); + + const page = renderTemplateRedirectController( + `/templates/${M.MockTemplate.name}`, + ); + + await waitFor(() => + expect(page.router.state.location.pathname).toEqual( + `/templates/${M.MockDefaultOrganization.name}/${M.MockTemplate.name}`, + ), + ); +}); From 1b4c8818d804b5b12e400d25793843ec7f2fde23 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Mon, 12 Aug 2024 18:51:42 +0000 Subject: [PATCH 7/8] ywdigydmf --- .../TemplatePage/TemplateRedirectController.test.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/site/src/pages/TemplatePage/TemplateRedirectController.test.tsx b/site/src/pages/TemplatePage/TemplateRedirectController.test.tsx index f90c56f796288..e511e29ed8f94 100644 --- a/site/src/pages/TemplatePage/TemplateRedirectController.test.tsx +++ b/site/src/pages/TemplatePage/TemplateRedirectController.test.tsx @@ -12,12 +12,12 @@ const renderTemplateRedirectController = (route: string) => { }; it("redirects from multi-org to single-org", async () => { - const page = renderTemplateRedirectController( + const { router } = renderTemplateRedirectController( `/templates/${M.MockTemplate.organization_name}/${M.MockTemplate.name}`, ); await waitFor(() => - expect(page.router.state.location.pathname).toEqual( + expect(router.state.location.pathname).toEqual( `/templates/${M.MockTemplate.name}`, ), ); @@ -28,12 +28,12 @@ it("redirects from single-org to multi-org", async () => { .spyOn(API, "getOrganizations") .mockResolvedValueOnce([M.MockDefaultOrganization, M.MockOrganization2]); - const page = renderTemplateRedirectController( + const { router } = renderTemplateRedirectController( `/templates/${M.MockTemplate.name}`, ); await waitFor(() => - expect(page.router.state.location.pathname).toEqual( + expect(router.state.location.pathname).toEqual( `/templates/${M.MockDefaultOrganization.name}/${M.MockTemplate.name}`, ), ); From 2281908c0574d2925e96af590012f503fc4c150e Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Mon, 12 Aug 2024 18:55:25 +0000 Subject: [PATCH 8/8] :( --- .../pages/TemplatePage/TemplateRedirectController.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/site/src/pages/TemplatePage/TemplateRedirectController.test.tsx b/site/src/pages/TemplatePage/TemplateRedirectController.test.tsx index e511e29ed8f94..459e94fe911f0 100644 --- a/site/src/pages/TemplatePage/TemplateRedirectController.test.tsx +++ b/site/src/pages/TemplatePage/TemplateRedirectController.test.tsx @@ -1,8 +1,8 @@ +import { waitFor } from "@testing-library/react"; import { API } from "api/api"; import * as M from "testHelpers/entities"; -import { TemplateRedirectController } from "./TemplateRedirectController"; import { renderWithAuth } from "testHelpers/renderHelpers"; -import { waitFor } from "@testing-library/react"; +import { TemplateRedirectController } from "./TemplateRedirectController"; const renderTemplateRedirectController = (route: string) => { return renderWithAuth(, {