feat: add redesigned organization settings sidebar (#15932)

resolves coder/internal#173, coder/internal#175

This PR does the following
1. Updates the left sidebar for organizations to use a dropdown to
select the organization
2. Move the create organization button inside the dropdown
3. Update the design of the create organization page
4. Do not display the organization in the dropdown if there is only 1
org to display

Figma:
https://www.figma.com/design/OR75XeUI0Z3ksqt1mHsNQw/Dashboard-v1?node-id=139-1380&m=dev

The loading state for the save button in the create organization form
will be handled separately after #14978 is completed.

Note: Since the dropdown is based off the cmdk component, navigation in
the dropdown is handled by the arrow keys, https://cmdk.paco.me/

<img width="560" alt="Screenshot 2025-01-03 at 21 11 26"
src="https://github.com/user-attachments/assets/ff6e61ab-c8d4-4f97-b603-306492e9bfec"
/>
<img width="641" alt="Screenshot 2025-01-03 at 21 11 39"
src="https://github.com/user-attachments/assets/fedb28e0-9ef3-4b0f-8665-06215338f351"
/>
<img width="1178" alt="Screenshot 2025-01-03 at 21 12 05"
src="https://github.com/user-attachments/assets/ee672533-2689-4b2e-a7bf-471ea72e1095"
/>
<img width="1177" alt="Screenshot 2025-01-03 at 21 12 39"
src="https://github.com/user-attachments/assets/f13824a6-2581-4bff-b5b6-2024c2e145a4"
/>
This commit is contained in:
Jaayden Halko
2025-01-08 13:01:00 +00:00
committed by GitHub
parent dcf0337455
commit 8a0094ce70
18 changed files with 465 additions and 353 deletions
+8 -8
View File
@@ -106,15 +106,15 @@ packages:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
fast-glob@3.3.2:
resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==}
fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fastq@1.17.1:
resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==}
fastq@1.18.0:
resolution: {integrity: sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==}
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
@@ -376,7 +376,7 @@ snapshots:
'@nodelib/fs.walk@1.2.8':
dependencies:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.17.1
fastq: 1.18.0
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -431,7 +431,7 @@ snapshots:
entities@4.5.0: {}
fast-glob@3.3.2:
fast-glob@3.3.3:
dependencies:
'@nodelib/fs.stat': 2.0.5
'@nodelib/fs.walk': 1.2.8
@@ -441,7 +441,7 @@ snapshots:
fast-levenshtein@2.0.6: {}
fastq@1.17.1:
fastq@1.18.0:
dependencies:
reusify: 1.0.4
@@ -478,7 +478,7 @@ snapshots:
globby@14.0.2:
dependencies:
'@sindresorhus/merge-streams': 2.3.0
fast-glob: 3.3.2
fast-glob: 3.3.3
ignore: 5.3.2
path-type: 5.0.0
slash: 5.1.0
+1 -1
View File
@@ -23,7 +23,7 @@ test("create group", async ({ page }) => {
await page.goto(`/organizations/${org.name}`);
// Navigate to groups page
await page.getByText("Groups").click();
await page.getByRole("link", { name: "Groups" }).click();
await expect(page).toHaveTitle(`Groups - Org ${org.name} - Coder`);
// Create a new group
+1 -1
View File
@@ -21,7 +21,7 @@ test("add and remove organization member", async ({ page }) => {
const { displayName } = await createOrganization(page);
// Navigate to members page
await page.getByText("Members").click();
await page.getByRole("link", { name: "Members" }).click();
await expect(page).toHaveTitle(`Members - ${displayName} - Coder`);
// Add a user to the org
+11 -1
View File
@@ -29,15 +29,25 @@ test("create and delete organization", async ({ page }) => {
await expectUrl(page).toHavePathName(`/organizations/${name}`);
await expect(page.getByText("Organization created.")).toBeVisible();
await page.goto(`/organizations/${name}/settings`, {
waitUntil: "domcontentloaded",
});
const newName = randomName();
await page.getByLabel("Slug").fill(newName);
await page.getByLabel("Description").fill(`Org description ${newName}`);
await page.getByRole("button", { name: /save/i }).click();
// Expect to be redirected when renaming the organization
await expectUrl(page).toHavePathName(`/organizations/${newName}`);
await expectUrl(page).toHavePathName(`/organizations/${newName}/settings`);
await expect(page.getByText("Organization settings updated.")).toBeVisible();
await page.goto(`/organizations/${newName}/settings`, {
waitUntil: "domcontentloaded",
});
// Expect to be redirected when renaming the organization
await expectUrl(page).toHavePathName(`/organizations/${newName}/settings`);
await page.getByRole("button", { name: "Delete this organization" }).click();
const dialog = page.getByTestId("dialog");
await dialog.getByLabel("Name").fill(newName);
+3 -3
View File
@@ -69,7 +69,7 @@ export const CommandList = forwardRef<
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
className={cn("max-h-96 overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
@@ -92,7 +92,7 @@ export const CommandGroup = forwardRef<
<CommandPrimitive.Group
ref={ref}
className={cn(
`overflow-hidden p-1 text-content-primary
`overflow-hidden p-2 text-content-primary
[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs
[&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-content-secondary`,
className,
@@ -119,7 +119,7 @@ export const CommandItem = forwardRef<
<CommandPrimitive.Item
ref={ref}
className={cn(
`relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none
`relative flex cursor-default gap-2 select-none text-content-secondary items-center rounded-sm px-2 py-2 text-sm font-medium outline-none
data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50
data-[selected=true]:bg-surface-secondary data-[selected=true]:text-content-primary
[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0`,
+1 -1
View File
@@ -24,7 +24,7 @@ export const PopoverContent = forwardRef<
align={align}
sideOffset={sideOffset}
className={cn(
`z-50 w-72 rounded-md border border-solid bg-surface-primary p-4
`z-50 w-72 rounded-md border border-solid bg-surface-primary
text-content-primary shadow-md outline-none
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
+1 -1
View File
@@ -67,7 +67,7 @@ export const SettingsSidebarNavItem: FC<SettingsSidebarNavItemProps> = ({
to={href}
className={({ isActive }) =>
cn(
"relative text-sm text-content-secondary no-underline font-medium py-2 px-3 hover:bg-surface-secondary rounded-md transition ease-in-out duration-150 ",
"relative text-sm text-content-secondary no-underline font-medium py-2 px-3 hover:bg-surface-secondary rounded-md transition ease-in-out duration-150",
{
"font-semibold text-content-primary": isActive,
},
@@ -39,8 +39,8 @@ const DeploymentSettingsLayout: FC = () => {
</BreadcrumbList>
</Breadcrumb>
<hr className="h-px border-none bg-border" />
<div className="px-6 max-w-screen-2xl">
<div className="flex flex-row gap-12 py-10">
<div className="px-10 max-w-screen-2xl">
<div className="flex flex-row gap-28 py-10">
<DeploymentSidebar />
<main css={{ flexGrow: 1 }}>
<Suspense fallback={<Loader />}>
@@ -4,7 +4,7 @@ import { withDashboardProvider } from "testHelpers/storybook";
import { DeploymentSidebarView } from "./DeploymentSidebarView";
const meta: Meta<typeof DeploymentSidebarView> = {
title: "modules/management/SidebarView",
title: "modules/management/DeploymentSidebarView",
component: DeploymentSidebarView,
decorators: [withDashboardProvider],
parameters: { showOrganizations: true },
@@ -15,7 +15,6 @@ import { RequirePermission } from "contexts/auth/RequirePermission";
import { useDashboard } from "modules/dashboard/useDashboard";
import { type FC, Suspense, createContext, useContext } from "react";
import { Outlet, useParams } from "react-router-dom";
import { OrganizationSidebar } from "./OrganizationSidebar";
export const OrganizationSettingsContext = createContext<
OrganizationSettingsValue | undefined
@@ -82,13 +81,10 @@ const OrganizationSettingsLayout: FC = () => {
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink
href="/organizations"
className="flex items-center gap-2"
>
<BreadcrumbPage className="flex items-center gap-2">
Organizations
<FeatureStageBadge contentType="beta" size="sm" />
</BreadcrumbLink>
</BreadcrumbPage>
</BreadcrumbItem>
{organization && (
<>
@@ -109,15 +105,10 @@ const OrganizationSettingsLayout: FC = () => {
</BreadcrumbList>
</Breadcrumb>
<hr className="h-px border-none bg-border" />
<div className="px-6 max-w-screen-2xl">
<div className="flex flex-row gap-12 py-10">
<OrganizationSidebar />
<main css={{ flexGrow: 1 }}>
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
</main>
</div>
<div className="px-10 max-w-screen-2xl">
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
</div>
</div>
</OrganizationSettingsContext.Provider>
@@ -47,9 +47,11 @@ export const OrganizationSidebar: FC = () => {
return canEditOrganization(org.permissions);
});
const organization = editableOrgs?.find((o) => o.name === organizationName);
return (
<OrganizationSidebarView
activeOrganizationName={organizationName}
activeOrganization={organization}
organizations={editableOrgs}
permissions={permissions}
/>
@@ -0,0 +1,19 @@
import { Loader } from "components/Loader/Loader";
import { type FC, Suspense } from "react";
import { Outlet } from "react-router-dom";
import { OrganizationSidebar } from "./OrganizationSidebar";
const OrganizationSidebarLayout: FC = () => {
return (
<div className="flex flex-row gap-28 py-10">
<OrganizationSidebar />
<main css={{ flexGrow: 1 }}>
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
</main>
</div>
);
};
export default OrganizationSidebarLayout;
@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
import { expect, userEvent, waitFor, within } from "@storybook/test";
import {
MockNoPermissions,
MockOrganization,
@@ -14,7 +15,7 @@ const meta: Meta<typeof OrganizationSidebarView> = {
decorators: [withDashboardProvider],
parameters: { showOrganizations: true },
args: {
activeOrganizationName: undefined,
activeOrganization: undefined,
organizations: [
{
...MockOrganization,
@@ -50,29 +51,163 @@ export const LoadingOrganizations: Story = {
export const NoCreateOrg: Story = {
args: {
activeOrganization: {
...MockOrganization,
permissions: { createOrganization: false },
},
permissions: {
...MockPermissions,
createOrganization: false,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: /My Organization/i }),
);
await waitFor(() =>
expect(canvas.queryByText("Create Organization")).not.toBeInTheDocument(),
);
},
};
export const OverflowDropdown: Story = {
args: {
activeOrganization: {
...MockOrganization,
permissions: { createOrganization: true },
},
permissions: {
...MockPermissions,
createOrganization: true,
},
organizations: [
{
...MockOrganization,
permissions: {},
},
{
...MockOrganization2,
permissions: {},
},
{
id: "my-organization-3-id",
name: "my-organization-3",
display_name: "My Organization 3",
description: "Another organization that gets used for stuff.",
icon: "/emojis/1f957.png",
created_at: "",
updated_at: "",
is_default: false,
permissions: {},
},
{
id: "my-organization-4-id",
name: "my-organization-4",
display_name: "My Organization 4",
description: "Another organization that gets used for stuff.",
icon: "/emojis/1f957.png",
created_at: "",
updated_at: "",
is_default: false,
permissions: {},
},
{
id: "my-organization-5-id",
name: "my-organization-5",
display_name: "My Organization 5",
description: "Another organization that gets used for stuff.",
icon: "/emojis/1f957.png",
created_at: "",
updated_at: "",
is_default: false,
permissions: {},
},
{
id: "my-organization-6-id",
name: "my-organization-6",
display_name: "My Organization 6",
description: "Another organization that gets used for stuff.",
icon: "/emojis/1f957.png",
created_at: "",
updated_at: "",
is_default: false,
permissions: {},
},
{
id: "my-organization-7-id",
name: "my-organization-7",
display_name: "My Organization 7",
description: "Another organization that gets used for stuff.",
icon: "/emojis/1f957.png",
created_at: "",
updated_at: "",
is_default: false,
permissions: {},
},
],
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
canvas.getByRole("button", { name: /My Organization/i }),
);
},
};
export const NoPermissions: Story = {
args: {
activeOrganization: {
...MockOrganization,
permissions: MockNoPermissions,
},
permissions: MockNoPermissions,
},
};
export const SelectedOrgNoMatch: Story = {
export const AllPermissions: Story = {
args: {
activeOrganizationName: MockOrganization.name,
organizations: [],
activeOrganization: {
...MockOrganization,
permissions: {
editOrganization: true,
editMembers: true,
editGroups: true,
auditOrganization: true,
assignOrgRole: true,
viewProvisioners: true,
viewIdpSyncSettings: true,
},
},
organizations: [
{
...MockOrganization,
permissions: {
editOrganization: true,
editMembers: true,
editGroups: true,
auditOrganization: true,
assignOrgRole: true,
viewProvisioners: true,
viewIdpSyncSettings: true,
},
},
],
},
};
export const SelectedOrgAdmin: Story = {
args: {
activeOrganizationName: MockOrganization.name,
activeOrganization: {
...MockOrganization,
permissions: {
editOrganization: true,
editMembers: true,
editGroups: true,
auditOrganization: true,
assignOrgRole: true,
},
},
organizations: [
{
...MockOrganization,
@@ -90,7 +225,15 @@ export const SelectedOrgAdmin: Story = {
export const SelectedOrgAuditor: Story = {
args: {
activeOrganizationName: MockOrganization.name,
activeOrganization: {
...MockOrganization,
permissions: {
editOrganization: false,
editMembers: false,
editGroups: false,
auditOrganization: true,
},
},
permissions: {
...MockPermissions,
createOrganization: false,
@@ -111,7 +254,15 @@ export const SelectedOrgAuditor: Story = {
export const SelectedOrgUserAdmin: Story = {
args: {
activeOrganizationName: MockOrganization.name,
activeOrganization: {
...MockOrganization,
permissions: {
editOrganization: false,
editMembers: true,
editGroups: true,
auditOrganization: false,
},
},
permissions: {
...MockPermissions,
createOrganization: false,
@@ -130,57 +281,6 @@ export const SelectedOrgUserAdmin: Story = {
},
};
export const MultiOrgAdminAndUserAdmin: Story = {
args: {
organizations: [
{
...MockOrganization,
permissions: {
editOrganization: false,
editMembers: false,
editGroups: false,
auditOrganization: true,
},
},
{
...MockOrganization2,
permissions: {
editOrganization: false,
editMembers: true,
editGroups: true,
auditOrganization: false,
},
},
],
},
};
export const SelectedMultiOrgAdminAndUserAdmin: Story = {
args: {
activeOrganizationName: MockOrganization2.name,
organizations: [
{
...MockOrganization,
permissions: {
editOrganization: false,
editMembers: false,
editGroups: false,
auditOrganization: true,
},
},
{
...MockOrganization2,
permissions: {
editOrganization: false,
editMembers: true,
editGroups: true,
auditOrganization: false,
},
},
],
},
};
export const OrgsDisabled: Story = {
parameters: {
showOrganizations: false,
@@ -1,18 +1,27 @@
import { cx } from "@emotion/css";
import AddIcon from "@mui/icons-material/Add";
import type { AuthorizationResponse, Organization } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { Button } from "components/Button/Button";
import {
Command,
CommandGroup,
CommandItem,
CommandList,
} from "components/Command/Command";
import { Loader } from "components/Loader/Loader";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
import {
Sidebar as BaseSidebar,
SettingsSidebarNavItem as SidebarNavSubItem,
SettingsSidebarNavItem,
} from "components/Sidebar/Sidebar";
import { Stack } from "components/Stack/Stack";
import type { Permissions } from "contexts/auth/permissions";
import { type ClassName, useClassName } from "hooks/useClassName";
import { ChevronDown, Plus } from "lucide-react";
import { useDashboard } from "modules/dashboard/useDashboard";
import type { FC, ReactNode } from "react";
import { Link, NavLink } from "react-router-dom";
import { type FC, useState } from "react";
import { useNavigate } from "react-router-dom";
export interface OrganizationWithPermissions extends Organization {
permissions: AuthorizationResponse;
@@ -20,7 +29,7 @@ export interface OrganizationWithPermissions extends Organization {
interface SidebarProps {
/** The active org name, if any. Overrides activeSettings. */
activeOrganizationName: string | undefined;
activeOrganization: OrganizationWithPermissions | undefined;
/** Organizations and their permissions or undefined if still fetching. */
organizations: OrganizationWithPermissions[] | undefined;
/** Site-wide permissions. */
@@ -31,7 +40,7 @@ interface SidebarProps {
* Organization settings left sidebar menu.
*/
export const OrganizationSidebarView: FC<SidebarProps> = ({
activeOrganizationName,
activeOrganization,
organizations,
permissions,
}) => {
@@ -41,7 +50,7 @@ export const OrganizationSidebarView: FC<SidebarProps> = ({
<BaseSidebar>
{showOrganizations && (
<OrganizationsSettingsNavigation
activeOrganizationName={activeOrganizationName}
activeOrganization={activeOrganization}
organizations={organizations}
permissions={permissions}
/>
@@ -56,7 +65,7 @@ function urlForSubpage(organizationName: string, subpage = ""): string {
interface OrganizationsSettingsNavigationProps {
/** The active org name if an org is being viewed. */
activeOrganizationName: string | undefined;
activeOrganization: OrganizationWithPermissions | undefined;
/** Organizations and their permissions or undefined if still fetching. */
organizations: OrganizationWithPermissions[] | undefined;
/** Site-wide permissions. */
@@ -64,187 +73,158 @@ interface OrganizationsSettingsNavigationProps {
}
/**
* Displays navigation for all organizations and a create organization link.
* Displays navigation items for the active organization and a combobox to
* switch between organizations.
*
* If organizations or their permissions are still loading, show a loader.
*
* If there are no organizations and the user does not have the create org
* permission, nothing is displayed.
*/
const OrganizationsSettingsNavigation: FC<
OrganizationsSettingsNavigationProps
> = ({ activeOrganizationName, organizations, permissions }) => {
// Wait for organizations and their permissions to load in.
if (!organizations) {
> = ({ activeOrganization, organizations, permissions }) => {
// Wait for organizations and their permissions to load
if (!organizations || !activeOrganization) {
return <Loader />;
}
if (organizations.length <= 0 && !permissions.createOrganization) {
return null;
}
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const navigate = useNavigate();
return (
<>
{permissions.createOrganization && (
<SidebarNavItem
active="auto"
href="/organizations/new"
icon={<AddIcon />}
>
New organization
</SidebarNavItem>
)}
{organizations.map((org) => (
<OrganizationSettingsNavigation
key={org.id}
organization={org}
active={org.name === activeOrganizationName}
/>
))}
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-expanded={isPopoverOpen}
className="w-60 justify-between p-2 h-11"
>
<div className="flex flex-row gap-2 items-center p-2 truncate">
{activeOrganization && (
<Avatar
size="sm"
src={activeOrganization.icon}
fallback={activeOrganization.display_name}
/>
)}
<span className="truncate">
{activeOrganization?.display_name || activeOrganization?.name}
</span>
</div>
<ChevronDown />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-60">
<Command loop>
<CommandList>
<CommandGroup className="pb-2">
{organizations.length > 1 && (
<div className="flex flex-col max-h-[260px] overflow-y-auto">
{organizations.map((organization) => (
<CommandItem
key={organization.id}
value={organization.name}
onSelect={() => {
setIsPopoverOpen(false);
navigate(urlForSubpage(organization.name));
}}
// There is currently an issue with the cmdk component for keyboard navigation
// https://github.com/pacocoursey/cmdk/issues/322
tabIndex={0}
>
<Avatar
size="sm"
src={organization.icon}
fallback={organization.display_name}
/>
<span className="truncate">
{organization?.display_name || organization?.name}
</span>
</CommandItem>
))}
</div>
)}
{permissions.createOrganization && (
<>
{organizations.length > 1 && (
<hr className="h-px my-2 border-none bg-border -mx-2" />
)}
<CommandItem
className="flex justify-center data-[selected=true]:bg-transparent"
onSelect={() => {
setIsPopoverOpen(false);
setTimeout(() => {
navigate("/organizations/new");
}, 200);
}}
>
<Plus /> Create Organization
</CommandItem>
</>
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<OrganizationSettingsNavigation
key={activeOrganization.id}
organization={activeOrganization}
/>
</>
);
};
interface OrganizationSettingsNavigationProps {
/** Whether this organization is currently selected. */
active: boolean;
/** The organization to display in the navigation. */
organization: OrganizationWithPermissions;
}
/**
* Displays navigation for a single organization.
*
* If inactive, no sub-menu items will be shown, just the organization name.
*
* If active, it will show sub-menu items based on the permissions.
*/
const OrganizationSettingsNavigation: FC<
OrganizationSettingsNavigationProps
> = ({ active, organization }) => {
> = ({ organization }) => {
return (
<>
<SidebarNavItem
active={active}
href={urlForSubpage(organization.name)}
icon={
<Avatar
variant="icon"
src={organization.icon}
fallback={organization.display_name}
/>
}
>
{organization.display_name}
</SidebarNavItem>
{active && (
<div className="flex flex-col gap-1 my-2 ml-11">
{organization.permissions.editOrganization && (
<SidebarNavSubItem end href={urlForSubpage(organization.name)}>
Settings
</SidebarNavSubItem>
)}
{organization.permissions.editMembers && (
<SidebarNavSubItem
href={urlForSubpage(organization.name, "members")}
>
Members
</SidebarNavSubItem>
)}
{organization.permissions.editGroups && (
<SidebarNavSubItem
href={urlForSubpage(organization.name, "groups")}
>
Groups
</SidebarNavSubItem>
)}
{organization.permissions.assignOrgRole && (
<SidebarNavSubItem href={urlForSubpage(organization.name, "roles")}>
Roles
</SidebarNavSubItem>
)}
{organization.permissions.viewProvisioners && (
<SidebarNavSubItem
href={urlForSubpage(organization.name, "provisioners")}
>
Provisioners
</SidebarNavSubItem>
)}
{organization.permissions.viewIdpSyncSettings && (
<SidebarNavSubItem
href={urlForSubpage(organization.name, "idp-sync")}
>
IdP Sync
</SidebarNavSubItem>
)}
</div>
)}
<div className="flex flex-col gap-1 my-2">
{organization.permissions.editMembers && (
<SettingsSidebarNavItem end href={urlForSubpage(organization.name)}>
Members
</SettingsSidebarNavItem>
)}
{organization.permissions.editGroups && (
<SettingsSidebarNavItem
href={urlForSubpage(organization.name, "groups")}
>
Groups
</SettingsSidebarNavItem>
)}
{organization.permissions.assignOrgRole && (
<SettingsSidebarNavItem
href={urlForSubpage(organization.name, "roles")}
>
Roles
</SettingsSidebarNavItem>
)}
{organization.permissions.viewProvisioners && (
<SettingsSidebarNavItem
href={urlForSubpage(organization.name, "provisioners")}
>
Provisioners
</SettingsSidebarNavItem>
)}
{organization.permissions.viewIdpSyncSettings && (
<SettingsSidebarNavItem
href={urlForSubpage(organization.name, "idp-sync")}
>
IdP Sync
</SettingsSidebarNavItem>
)}
{organization.permissions.editOrganization && (
<SettingsSidebarNavItem
href={urlForSubpage(organization.name, "settings")}
>
Settings
</SettingsSidebarNavItem>
)}
</div>
</>
);
};
interface SidebarNavItemProps {
active?: boolean | "auto";
children?: ReactNode;
icon?: ReactNode;
href: string;
}
const SidebarNavItem: FC<SidebarNavItemProps> = ({
active,
children,
href,
icon,
}) => {
const link = useClassName(classNames.link, []);
const activeLink = useClassName(classNames.activeLink, []);
const content = (
<Stack alignItems="center" spacing={1.5} direction="row">
{icon}
{children}
</Stack>
);
if (active === "auto") {
return (
<NavLink
to={href}
className={({ isActive }) => cx([link, isActive && activeLink])}
>
{content}
</NavLink>
);
}
return (
<Link to={href} className={cx([link, active && activeLink])}>
{content}
</Link>
);
};
const classNames = {
link: (css, theme) => css`
color: inherit;
display: block;
font-size: 14px;
text-decoration: none;
padding: 10px 12px 10px 16px;
border-radius: 4px;
transition: background-color 0.15s ease-in-out;
position: relative;
&:hover {
background-color: ${theme.palette.action.hover};
}
border-left: 3px solid transparent;
`,
activeLink: (css, theme) => css`
border-left-color: ${theme.palette.primary.main};
border-top-left-radius: 0;
border-bottom-left-radius: 0;
`,
} satisfies Record<string, ClassName>;
@@ -18,15 +18,17 @@ const CreateOrganizationPage: FC = () => {
const error = createOrganizationMutation.error;
return (
<CreateOrganizationPageView
error={error}
isEntitled={feats.multiple_organizations}
onSubmit={async (values) => {
await createOrganizationMutation.mutateAsync(values);
displaySuccess("Organization created.");
navigate(`/organizations/${values.name}`);
}}
/>
<main className="py-20 sm:py-7">
<CreateOrganizationPageView
error={error}
isEntitled={feats.multiple_organizations}
onSubmit={async (values) => {
await createOrganizationMutation.mutateAsync(values);
displaySuccess("Organization created.");
navigate(`/organizations/${values.name}`);
}}
/>
</main>
);
};
@@ -5,25 +5,20 @@ import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Badges, PremiumBadge } from "components/Badges/Badges";
import { Button } from "components/Button/Button";
import { ChooseOne, Cond } from "components/Conditionals/ChooseOne";
import {
FormFields,
FormFooter,
FormSection,
HorizontalForm,
} from "components/Form/Form";
import { IconField } from "components/IconField/IconField";
import { Paywall } from "components/Paywall/Paywall";
import { PopoverPaywall } from "components/Paywall/PopoverPaywall";
import { SettingsHeader } from "components/SettingsHeader/SettingsHeader";
import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/deprecated/Popover/Popover";
import { useFormik } from "formik";
import { ArrowLeft } from "lucide-react";
import type { FC } from "react";
import { useNavigate } from "react-router-dom";
import { Link } from "react-router-dom";
import { docs } from "utils/docs";
import {
displayNameValidator,
@@ -64,74 +59,80 @@ export const CreateOrganizationPageView: FC<
validationSchema,
onSubmit,
});
const navigate = useNavigate();
const getFieldHelpers = getFormHelpers(form, error);
return (
<Stack>
<div>
<SettingsHeader
title="New Organization"
description="Organize your deployment into multiple platform teams with unique provisioners, templates, groups, and members."
/>
<div className="flex flex-row font-medium">
<div className="absolute top-40 left-12">
<Link
to="/organizations"
className="flex flex-row items-center gap-2 no-underline text-content-secondary hover:text-content-primary"
>
<ArrowLeft size={20} />
Go Back
</Link>
</div>
<div className="flex flex-col gap-4 w-full min-w-72 mx-auto">
<div className="flex flex-col items-center">
{Boolean(error) && !isApiValidationError(error) && (
<div css={{ marginBottom: 32 }}>
<ErrorAlert error={error} />
</div>
)}
{Boolean(error) && !isApiValidationError(error) && (
<div css={{ marginBottom: 32 }}>
<ErrorAlert error={error} />
</div>
)}
<Badges>
<Popover mode="hover">
{isEntitled && (
<PopoverTrigger>
<span>
<PremiumBadge />
</span>
</PopoverTrigger>
)}
<Badges>
<Popover mode="hover">
{isEntitled && (
<PopoverTrigger>
<span>
<PremiumBadge />
</span>
</PopoverTrigger>
)}
<PopoverContent css={{ transform: "translateY(-28px)" }}>
<PopoverPaywall
message="Organizations"
description="Create multiple organizations within a single Coder deployment, allowing several platform teams to operate with isolated users, templates, and distinct underlying infrastructure."
documentationLink={docs("/admin/users/organizations")}
/>
</PopoverContent>
</Popover>
</Badges>
<PopoverContent css={{ transform: "translateY(-28px)" }}>
<PopoverPaywall
<header className="flex flex-col items-center">
<h1 className="text-3xl font-semibold m-0">New Organization</h1>
<p className="max-w-md text-sm text-content-secondary text-center">
Organize your deployment into multiple platform teams with unique
provisioners, templates, groups, and members.
</p>
</header>
</div>
<ChooseOne>
<Cond condition={!isEntitled}>
<div className="min-w-fit mx-auto">
<Paywall
message="Organizations"
description="Create multiple organizations within a single Coder deployment, allowing several platform teams to operate with isolated users, templates, and distinct underlying infrastructure."
documentationLink={docs("/admin/users/organizations")}
/>
</PopoverContent>
</Popover>
</Badges>
</div>
<ChooseOne>
<Cond condition={!isEntitled}>
<Paywall
message="Organizations"
description="Create multiple organizations within a single Coder deployment, allowing several platform teams to operate with isolated users, templates, and distinct underlying infrastructure."
documentationLink={docs("/admin/users/organizations")}
/>
</Cond>
<Cond>
<HorizontalForm
onSubmit={form.handleSubmit}
aria-label="Organization settings form"
>
<FormSection
title="General info"
description="The name and description of the organization."
>
<fieldset
disabled={form.isSubmitting}
css={{
border: "unset",
padding: 0,
margin: 0,
width: "100%",
}}
</div>
</Cond>
<Cond>
<div className="flex flex-col gap-4 w-full max-w-xl min-w-72 mx-auto">
<form
onSubmit={form.handleSubmit}
aria-label="Organization settings form"
className="flex flex-col gap-6 w-full"
>
<FormFields>
<fieldset
disabled={form.isSubmitting}
className="flex flex-col gap-6 w-full border-none"
>
<TextField
{...getFieldHelpers("name")}
onChange={onChangeTrimmed(form)}
autoFocus
fullWidth
label="Slug"
/>
@@ -143,29 +144,33 @@ export const CreateOrganizationPageView: FC<
<TextField
{...getFieldHelpers("description")}
multiline
fullWidth
label="Description"
rows={2}
/>
<IconField
{...getFieldHelpers("icon")}
onChange={onChangeTrimmed(form)}
fullWidth
onPickEmoji={(value) => form.setFieldValue("icon", value)}
/>
</FormFields>
</fieldset>
</FormSection>
<FormFooter>
<Button type="submit" disabled={form.isSubmitting}>
{form.isSubmitting && <Spinner />}
Save
</Button>
</FormFooter>
</HorizontalForm>
</Cond>
</ChooseOne>
</Stack>
</fieldset>
<div className="flex flex-row gap-2">
<Button type="submit" disabled={form.isSubmitting}>
{form.isSubmitting && <Spinner />}
Save
</Button>
<Button
variant="outline"
type="button"
onClick={() => navigate("/organizations")}
>
Cancel
</Button>
</div>
</form>
</div>
</Cond>
</ChooseOne>
</div>
</div>
);
};
@@ -89,7 +89,7 @@ const OrganizationSettingsPage: FC = () => {
organizationId: organization.id,
req: values,
});
navigate(`/organizations/${updatedOrganization.name}`);
navigate(`/organizations/${updatedOrganization.name}/settings`);
displaySuccess("Organization settings updated.");
}}
onDeleteOrganization={() => {
+6 -3
View File
@@ -37,6 +37,9 @@ const DeploymentSettingsProvider = lazy(
const OrganizationSettingsLayout = lazy(
() => import("./modules/management/OrganizationSettingsLayout"),
);
const OrganizationSidebarLayout = lazy(
() => import("./modules/management/OrganizationSidebarLayout"),
);
const CliAuthenticationPage = lazy(
() => import("./pages/CliAuthPage/CliAuthPage"),
);
@@ -427,9 +430,8 @@ export const router = createBrowserRouter(
{/* General settings for the default org can omit the organization name */}
<Route index element={<OrganizationSettingsPage />} />
<Route path=":organization">
<Route index element={<OrganizationSettingsPage />} />
<Route path="members" element={<OrganizationMembersPage />} />
<Route path=":organization" element={<OrganizationSidebarLayout />}>
<Route index element={<OrganizationMembersPage />} />
{groupsRouter()}
<Route path="roles">
<Route index element={<OrganizationCustomRolesPage />} />
@@ -441,6 +443,7 @@ export const router = createBrowserRouter(
element={<OrganizationProvisionersPage />}
/>
<Route path="idp-sync" element={<OrganizationIdPSyncPage />} />
<Route path="settings" element={<OrganizationSettingsPage />} />
</Route>
</Route>