refactor: remove ChooseOne component (#24983)

This commit is contained in:
Kayla はな
2026-05-06 12:08:51 -06:00
committed by GitHub
parent 894a1e0f7f
commit 4e1dccaabd
15 changed files with 774 additions and 830 deletions
@@ -1,71 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ChooseOne, Cond } from "./ChooseOne";
const meta: Meta<typeof ChooseOne> = {
title: "components/Conditionals/ChooseOne",
component: ChooseOne,
};
export default meta;
type Story = StoryObj<typeof ChooseOne>;
export const FirstIsTrue: Story = {
args: {
children: [
<Cond key="1" condition>
The first one shows.
</Cond>,
<Cond key="2" condition={false}>
The second one does not show.
</Cond>,
<Cond key="3">The default does not show.</Cond>,
],
},
};
export const SecondIsTrue: Story = {
args: {
children: [
<Cond key="1" condition={false}>
The first one does not show.
</Cond>,
<Cond key="2" condition>
The second one shows.
</Cond>,
<Cond key="3">The default does not show.</Cond>,
],
},
};
export const AllAreTrue: Story = {
args: {
children: [
<Cond key="1" condition>
Only the first one shows.
</Cond>,
<Cond key="2" condition>
The second one does not show.
</Cond>,
<Cond key="3">The default does not show.</Cond>,
],
},
};
export const NoneAreTrue: Story = {
args: {
children: [
<Cond key="1" condition={false}>
The first one does not show.
</Cond>,
<Cond key="2" condition={false}>
The second one does not show.
</Cond>,
<Cond key="3">The default shows.</Cond>,
],
},
};
export const OneCond: Story = {
args: {
children: <Cond>An only child renders.</Cond>,
},
};
@@ -1,53 +0,0 @@
import {
Children,
type FC,
type JSX,
type PropsWithChildren,
type ReactNode,
} from "react";
interface CondProps {
condition?: boolean;
children?: ReactNode;
}
/**
* Wrapper component that attaches a condition to a child component so that ChooseOne can
* determine which child to render. The last Cond in a ChooseOne is the fallback case and
* should not have a condition.
* @param condition boolean expression indicating whether the child should be rendered, or undefined
* @returns child. Note that Cond alone does not enforce the condition; it should be used inside ChooseOne.
* @deprecated Use standard conditional rendering (ternary operators or && expressions) instead.
*/
export const Cond: FC<CondProps> = ({ children }) => {
return <>{children}</>;
};
/**
* Wrapper component for rendering exactly one of its children. Wrap each child in Cond to associate it
* with a condition under which it should be rendered. If no conditions are met, the final child
* will be rendered.
* @returns one of its children, or null if there are no children
* @throws an error if its last child has a condition prop, or any non-final children do not have a condition prop
* @deprecated Use standard conditional rendering (ternary operators or && expressions) instead.
*/
export const ChooseOne: FC<PropsWithChildren> = ({ children }) => {
const childArray = Children.toArray(children) as JSX.Element[];
if (childArray.length === 0) {
return null;
}
const conditionedOptions = childArray.slice(0, childArray.length - 1);
const defaultCase = childArray[childArray.length - 1];
if (defaultCase.props.condition !== undefined) {
throw new Error(
"The last Cond in a ChooseOne was given a condition prop, but it is the default case.",
);
}
if (conditionedOptions.some((cond) => cond.props.condition === undefined)) {
throw new Error(
"A non-final Cond in a ChooseOne does not have a condition prop or the prop is undefined.",
);
}
const chosen = conditionedOptions.find((child) => child.props.condition);
return chosen ?? defaultCase;
};
+42 -61
View File
@@ -6,7 +6,6 @@ import type {
WorkspaceAgent,
WorkspaceAgentDevcontainer,
} from "#/api/typesGenerated";
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
import {
HelpPopover,
HelpPopoverContent,
@@ -195,34 +194,28 @@ const ConnectedStatus: FC<AgentStatusProps> = ({ agent }) => {
if (agent.scripts.length === 0) {
return <ReadyLifecycle />;
}
return (
<ChooseOne>
<Cond condition={agent.lifecycle_state === "ready"}>
<ReadyLifecycle />
</Cond>
<Cond condition={agent.lifecycle_state === "start_timeout"}>
<StartTimeoutLifecycle agent={agent} />
</Cond>
<Cond condition={agent.lifecycle_state === "start_error"}>
<StartErrorLifecycle agent={agent} />
</Cond>
<Cond condition={agent.lifecycle_state === "shutting_down"}>
<ShuttingDownLifecycle />
</Cond>
<Cond condition={agent.lifecycle_state === "shutdown_timeout"}>
<ShutdownTimeoutLifecycle agent={agent} />
</Cond>
<Cond condition={agent.lifecycle_state === "shutdown_error"}>
<ShutdownErrorLifecycle agent={agent} />
</Cond>
<Cond condition={agent.lifecycle_state === "off"}>
<OffLifecycle />
</Cond>
<Cond>
<StartingLifecycle />
</Cond>
</ChooseOne>
);
if (agent.lifecycle_state === "ready") {
return <ReadyLifecycle />;
}
if (agent.lifecycle_state === "start_timeout") {
return <StartTimeoutLifecycle agent={agent} />;
}
if (agent.lifecycle_state === "start_error") {
return <StartErrorLifecycle agent={agent} />;
}
if (agent.lifecycle_state === "shutting_down") {
return <ShuttingDownLifecycle />;
}
if (agent.lifecycle_state === "shutdown_timeout") {
return <ShutdownTimeoutLifecycle agent={agent} />;
}
if (agent.lifecycle_state === "shutdown_error") {
return <ShutdownErrorLifecycle agent={agent} />;
}
if (agent.lifecycle_state === "off") {
return <OffLifecycle />;
}
return <StartingLifecycle />;
};
const DisconnectedStatus: FC = () => {
@@ -265,44 +258,32 @@ const TimeoutStatus: FC<AgentStatusProps> = ({ agent }) => (
);
export const AgentStatus: FC<AgentStatusProps> = ({ agent }) => {
return (
<ChooseOne>
<Cond condition={agent.status === "connected"}>
<ConnectedStatus agent={agent} />
</Cond>
<Cond condition={agent.status === "disconnected"}>
<DisconnectedStatus />
</Cond>
<Cond condition={agent.status === "timeout"}>
<TimeoutStatus agent={agent} />
</Cond>
<Cond>
<ConnectingStatus />
</Cond>
</ChooseOne>
);
if (agent.status === "connected") {
return <ConnectedStatus agent={agent} />;
}
if (agent.status === "disconnected") {
return <DisconnectedStatus />;
}
if (agent.status === "timeout") {
return <TimeoutStatus agent={agent} />;
}
return <ConnectingStatus />;
};
const SubAgentStatus: FC<SubAgentStatusProps> = ({ agent }) => {
if (!agent) {
return <DisconnectedStatus />;
}
return (
<ChooseOne>
<Cond condition={agent.status === "connected"}>
<ConnectedStatus agent={agent} />
</Cond>
<Cond condition={agent.status === "disconnected"}>
<DisconnectedStatus />
</Cond>
<Cond condition={agent.status === "timeout"}>
<TimeoutStatus agent={agent} />
</Cond>
<Cond>
<ConnectingStatus />
</Cond>
</ChooseOne>
);
if (agent.status === "connected") {
return <ConnectedStatus agent={agent} />;
}
if (agent.status === "disconnected") {
return <DisconnectedStatus />;
}
if (agent.status === "timeout") {
return <TimeoutStatus agent={agent} />;
}
return <ConnectingStatus />;
};
const DevcontainerStartError: FC<AgentStatusProps> = ({ agent }) => (
+78 -63
View File
@@ -1,6 +1,5 @@
import type { ComponentProps, FC } from "react";
import type { AuditLog } from "#/api/typesGenerated";
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
import { EmptyState } from "#/components/EmptyState/EmptyState";
import { Margins } from "#/components/Margins/Margins";
import {
@@ -63,8 +62,8 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
<PageHeaderSubtitle>View events in your audit log.</PageHeaderSubtitle>
</PageHeader>
<ChooseOne>
<Cond condition={isAuditLogVisible}>
{isAuditLogVisible ? (
<>
<AuditFilter {...filterProps} />
<PaginationContainer
@@ -73,69 +72,85 @@ export const AuditPageView: FC<AuditPageViewProps> = ({
>
<Table>
<TableBody>
<ChooseOne>
{/* Error condition should just show an empty table. */}
<Cond condition={Boolean(error)}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState message="An error occurred while loading audit logs" />
</TableCell>
</TableRow>
</Cond>
<Cond condition={isLoading}>
<TableLoader />
</Cond>
<Cond condition={isEmpty}>
<ChooseOne>
<Cond condition={isNonInitialPage}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState message="No audit logs available on this page" />
</TableCell>
</TableRow>
</Cond>
<Cond>
<TableRow>
<TableCell colSpan={999}>
<EmptyState message="No audit logs available" />
</TableCell>
</TableRow>
</Cond>
</ChooseOne>
</Cond>
<Cond>
{auditLogs && (
<Timeline
items={auditLogs}
getDate={(log) => new Date(log.time)}
row={(log) => (
<AuditLogRow
key={log.id}
auditLog={log}
showOrgDetails={showOrgDetails}
/>
)}
/>
)}
</Cond>
</ChooseOne>
<AuditTableBody
auditLogs={auditLogs}
error={error}
isLoading={isLoading}
isEmpty={isEmpty}
isNonInitialPage={isNonInitialPage}
showOrgDetails={showOrgDetails}
/>
</TableBody>
</Table>
</PaginationContainer>
</Cond>
<Cond>
<PaywallPremium
message="Audit logs"
description="Audit logs allow you to monitor user operations on your deployment. You need a Premium license to use this feature."
documentationLink={docs("/admin/security/audit-logs")}
/>
</Cond>
</ChooseOne>
</>
) : (
<PaywallPremium
message="Audit logs"
description="Audit logs allow you to monitor user operations on your deployment. You need a Premium license to use this feature."
documentationLink={docs("/admin/security/audit-logs")}
/>
)}
</Margins>
);
};
interface AuditTableBodyProps {
auditLogs: readonly AuditLog[] | undefined;
error: unknown;
isLoading: boolean;
isEmpty: boolean;
isNonInitialPage: boolean;
showOrgDetails: boolean;
}
const AuditTableBody: FC<AuditTableBodyProps> = ({
auditLogs,
error,
isLoading,
isEmpty,
isNonInitialPage,
showOrgDetails,
}) => {
// An error renders as an empty table.
if (error) {
return (
<TableRow>
<TableCell colSpan={999}>
<EmptyState message="An error occurred while loading audit logs" />
</TableCell>
</TableRow>
);
}
if (isLoading) {
return <TableLoader />;
}
if (isEmpty) {
const emptyMessage = isNonInitialPage
? "No audit logs available on this page"
: "No audit logs available";
return (
<TableRow>
<TableCell colSpan={999}>
<EmptyState message={emptyMessage} />
</TableCell>
</TableRow>
);
}
if (!auditLogs) {
return null;
}
return (
<Timeline
items={auditLogs}
getDate={(log) => new Date(log.time)}
row={(log) => (
<AuditLogRow
key={log.id}
auditLog={log}
showOrgDetails={showOrgDetails}
/>
)}
/>
);
};
@@ -1,6 +1,5 @@
import type { ComponentProps, FC } from "react";
import type { ConnectionLog } from "#/api/typesGenerated";
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
import { EmptyState } from "#/components/EmptyState/EmptyState";
import { Margins } from "#/components/Margins/Margins";
import {
@@ -64,8 +63,8 @@ export const ConnectionLogPageView: FC<ConnectionLogPageViewProps> = ({
</PageHeaderSubtitle>
</PageHeader>
<ChooseOne>
<Cond condition={isConnectionLogVisible}>
{isConnectionLogVisible ? (
<>
<ConnectionLogFilter {...filterProps} />
<PaginationContainer
@@ -74,65 +73,76 @@ export const ConnectionLogPageView: FC<ConnectionLogPageViewProps> = ({
>
<Table>
<TableBody>
<ChooseOne>
{/* Error condition should just show an empty table. */}
<Cond condition={Boolean(error)}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState message="An error occurred while loading connection logs" />
</TableCell>
</TableRow>
</Cond>
<Cond condition={isLoading}>
<TableLoader />
</Cond>
<Cond condition={isEmpty}>
<ChooseOne>
<Cond condition={isNonInitialPage}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState message="No connection logs available on this page" />
</TableCell>
</TableRow>
</Cond>
<Cond>
<TableRow>
<TableCell colSpan={999}>
<EmptyState message="No connection logs available" />
</TableCell>
</TableRow>
</Cond>
</ChooseOne>
</Cond>
<Cond>
{connectionLogs && (
<Timeline
items={connectionLogs}
getDate={(log) => new Date(log.connect_time)}
row={(log) => (
<ConnectionLogRow key={log.id} connectionLog={log} />
)}
/>
)}
</Cond>
</ChooseOne>
<ConnectionLogTableBody
connectionLogs={connectionLogs}
error={error}
isLoading={isLoading}
isEmpty={isEmpty}
isNonInitialPage={isNonInitialPage}
/>
</TableBody>
</Table>
</PaginationContainer>
</Cond>
<Cond>
<PaywallPremium
message="Connection logs"
description="Connection logs allow you to see how and when users connect to workspaces. You need a Premium license to use this feature."
documentationLink={docs("/admin/monitoring/connection-logs")}
/>
</Cond>
</ChooseOne>
</>
) : (
<PaywallPremium
message="Connection logs"
description="Connection logs allow you to see how and when users connect to workspaces. You need a Premium license to use this feature."
documentationLink={docs("/admin/monitoring/connection-logs")}
/>
)}
</Margins>
);
};
interface ConnectionLogTableBodyProps {
connectionLogs: readonly ConnectionLog[] | undefined;
error: unknown;
isLoading: boolean;
isEmpty: boolean;
isNonInitialPage: boolean;
}
const ConnectionLogTableBody: FC<ConnectionLogTableBodyProps> = ({
connectionLogs,
error,
isLoading,
isEmpty,
isNonInitialPage,
}) => {
// An error renders as an empty table.
if (error) {
return (
<TableRow>
<TableCell colSpan={999}>
<EmptyState message="An error occurred while loading connection logs" />
</TableCell>
</TableRow>
);
}
if (isLoading) {
return <TableLoader />;
}
if (isEmpty) {
const emptyMessage = isNonInitialPage
? "No connection logs available on this page"
: "No connection logs available";
return (
<TableRow>
<TableCell colSpan={999}>
<EmptyState message={emptyMessage} />
</TableCell>
</TableRow>
);
}
if (!connectionLogs) {
return null;
}
return (
<Timeline
items={connectionLogs}
getDate={(log) => new Date(log.connect_time)}
row={(log) => <ConnectionLogRow key={log.id} connectionLog={log} />}
/>
);
};
@@ -7,7 +7,6 @@ import {
organizationIdpSyncSettings,
patchOrganizationSyncSettings,
} from "#/api/queries/idpsync";
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
import { Link } from "#/components/Link/Link";
import { Loader } from "#/components/Loader/Loader";
import { PaywallPremium } from "#/components/Paywall/PaywallPremium";
@@ -76,40 +75,37 @@ const IdpOrgSyncPage: FC = () => {
</div>
<ExportPolicyButton syncSettings={settingsQuery.data} />
</header>
<ChooseOne>
<Cond condition={!isIdpSyncEnabled}>
<PaywallPremium
message="IdP Organization Sync"
description="Configure organization mappings to synchronize claims in your auth provider to organizations within Coder. You need a Premium license to use this feature."
documentationLink={docs("/admin/users/idp-sync")}
/>
</Cond>
<Cond>
<IdpOrgSyncPageView
organizationSyncSettings={settingsQuery.data}
claimFieldValues={fieldValuesQuery.data}
organizations={organizations}
onSyncFieldChange={setField}
onSubmit={async (data) => {
try {
await patchOrganizationSyncSettingsMutation.mutateAsync(data);
toast.success("Organization sync settings updated.");
} catch (error) {
toast.error(
getErrorMessage(
error,
"Failed to update organization IdP sync settings.",
),
{
description: getErrorDetail(error),
},
);
}
}}
error={settingsQuery.error || fieldValuesQuery.error}
/>
</Cond>
</ChooseOne>
{!isIdpSyncEnabled ? (
<PaywallPremium
message="IdP Organization Sync"
description="Configure organization mappings to synchronize claims in your auth provider to organizations within Coder. You need a Premium license to use this feature."
documentationLink={docs("/admin/users/idp-sync")}
/>
) : (
<IdpOrgSyncPageView
organizationSyncSettings={settingsQuery.data}
claimFieldValues={fieldValuesQuery.data}
organizations={organizations}
onSyncFieldChange={setField}
onSubmit={async (data) => {
try {
await patchOrganizationSyncSettingsMutation.mutateAsync(data);
toast.success("Organization sync settings updated.");
} catch (error) {
toast.error(
getErrorMessage(
error,
"Failed to update organization IdP sync settings.",
),
{
description: getErrorDetail(error),
},
);
}
}}
error={settingsQuery.error || fieldValuesQuery.error}
/>
)}
</div>
</>
);
@@ -17,7 +17,6 @@ import {
ComboboxList,
ComboboxTrigger,
} from "#/components/Combobox/Combobox";
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
import {
Dialog,
DialogContent,
@@ -408,27 +407,23 @@ const IdpMappingTable: FC<IdpMappingTableProps> = ({ isEmpty, children }) => {
</TableRow>
</TableHeader>
<TableBody>
<ChooseOne>
<Cond condition={isEmpty}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message="No organization mappings"
isCompact
cta={
<Link
href={docs("/admin/users/idp-sync#organization-sync")}
>
How to set up IdP organization sync
</Link>
}
/>
</TableCell>
</TableRow>
</Cond>
<Cond>{children}</Cond>
</ChooseOne>
{isEmpty ? (
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message="No organization mappings"
isCompact
cta={
<Link href={docs("/admin/users/idp-sync#organization-sync")}>
How to set up IdP organization sync
</Link>
}
/>
</TableCell>
</TableRow>
) : (
children
)}
</TableBody>
</Table>
);
+66 -59
View File
@@ -7,7 +7,6 @@ import { AvatarData } from "#/components/Avatar/AvatarData";
import { AvatarDataSkeleton } from "#/components/Avatar/AvatarDataSkeleton";
import { Badge } from "#/components/Badge/Badge";
import { Button } from "#/components/Button/Button";
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
import { EmptyState } from "#/components/EmptyState/EmptyState";
import { PaywallPremium } from "#/components/Paywall/PaywallPremium";
import { Skeleton } from "#/components/Skeleton/Skeleton";
@@ -37,68 +36,76 @@ export const GroupsPageView: FC<GroupsPageViewProps> = ({
canCreateGroup,
groupsEnabled,
}) => {
const isLoading = Boolean(groups === undefined);
const isEmpty = Boolean(groups && groups.length === 0);
if (!groupsEnabled) {
return (
<PaywallPremium
message="Groups"
description="Organize users into groups with restricted access to templates. You need a Premium license to use this feature."
documentationLink={docs("/admin/users/groups-roles")}
/>
);
}
return (
<ChooseOne>
<Cond condition={!groupsEnabled}>
<PaywallPremium
message="Groups"
description="Organize users into groups with restricted access to templates. You need a Premium license to use this feature."
documentationLink={docs("/admin/users/groups-roles")}
/>
</Cond>
<Cond>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-2/5">Name</TableHead>
<TableHead className="w-3/5">Users</TableHead>
<TableHead className="w-auto" />
</TableRow>
</TableHeader>
<TableBody>
<ChooseOne>
<Cond condition={isLoading}>
<TableLoader />
</Cond>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-2/5">Name</TableHead>
<TableHead className="w-3/5">Users</TableHead>
<TableHead className="w-auto" />
</TableRow>
</TableHeader>
<TableBody>
<GroupsTableBody groups={groups} canCreateGroup={canCreateGroup} />
</TableBody>
</Table>
);
};
<Cond condition={isEmpty}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message="No groups yet"
description={
canCreateGroup
? "Create your first group"
: "You don't have permission to create a group"
}
cta={
canCreateGroup && (
<Button asChild>
<RouterLink to="create">
<PlusIcon className="size-icon-sm" />
Create group
</RouterLink>
</Button>
)
}
/>
</TableCell>
</TableRow>
</Cond>
interface GroupsTableBodyProps {
groups: Group[] | undefined;
canCreateGroup: boolean;
}
<Cond>
{groups?.map((group) => (
<GroupRow key={group.id} group={group} />
))}
</Cond>
</ChooseOne>
</TableBody>
</Table>
</Cond>
</ChooseOne>
const GroupsTableBody: FC<GroupsTableBodyProps> = ({
groups,
canCreateGroup,
}) => {
if (groups === undefined) {
return <TableLoader />;
}
if (groups.length === 0) {
return (
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message="No groups yet"
description={
canCreateGroup
? "Create your first group"
: "You don't have permission to create a group"
}
cta={
canCreateGroup && (
<Button asChild>
<RouterLink to="create">
<PlusIcon className="size-icon-sm" />
Create group
</RouterLink>
</Button>
)
}
/>
</TableCell>
</TableRow>
);
}
return (
<>
{groups.map((group) => (
<GroupRow key={group.id} group={group} />
))}
</>
);
};
@@ -9,7 +9,6 @@ import type { CreateOrganizationRequest } from "#/api/typesGenerated";
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 { IconField } from "#/components/IconField/IconField";
import { PaywallPremium } from "#/components/Paywall/PaywallPremium";
import { PopoverPaywall } from "#/components/Paywall/PopoverPaywall";
@@ -112,67 +111,64 @@ export const CreateOrganizationPageView: FC<
</p>
</header>
</div>
<ChooseOne>
<Cond condition={!isEntitled}>
<div className="min-w-fit mx-auto">
<PaywallPremium
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")}
/>
</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"
{!isEntitled ? (
<div className="min-w-fit mx-auto">
<PaywallPremium
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")}
/>
</div>
) : (
<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"
>
<fieldset
disabled={form.isSubmitting}
className="flex flex-col gap-6 w-full border-none"
>
<fieldset
disabled={form.isSubmitting}
className="flex flex-col gap-6 w-full border-none"
<TextField
{...getFieldHelpers("name")}
onChange={onChangeTrimmed(form)}
fullWidth
label="Slug"
/>
<TextField
{...getFieldHelpers("display_name")}
fullWidth
label="Display name"
/>
<TextField
{...getFieldHelpers("description")}
multiline
label="Description"
rows={2}
/>
<IconField
{...getFieldHelpers("icon")}
onChange={onChangeTrimmed(form)}
onPickEmoji={(value) => form.setFieldValue("icon", value)}
/>
</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")}
>
<TextField
{...getFieldHelpers("name")}
onChange={onChangeTrimmed(form)}
fullWidth
label="Slug"
/>
<TextField
{...getFieldHelpers("display_name")}
fullWidth
label="Display name"
/>
<TextField
{...getFieldHelpers("description")}
multiline
label="Description"
rows={2}
/>
<IconField
{...getFieldHelpers("icon")}
onChange={onChangeTrimmed(form)}
onPickEmoji={(value) => form.setFieldValue("icon", value)}
/>
</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>
Cancel
</Button>
</div>
</form>
</div>
)}
</div>
</div>
);
@@ -3,7 +3,6 @@ import type { FC } from "react";
import { Link as RouterLink, useNavigate } from "react-router";
import type { AssignableRoles, Role } from "#/api/typesGenerated";
import { Button, Button as ShadcnButton } from "#/components/Button/Button";
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
import {
DropdownMenu,
DropdownMenuContent,
@@ -117,8 +116,6 @@ const RoleTable: FC<RoleTableProps> = ({
canDeleteOrgRole,
onDeleteRole,
}) => {
const isLoading = roles === undefined;
const isEmpty = Boolean(roles && roles.length === 0);
return (
<Table>
<TableHeader>
@@ -129,58 +126,76 @@ const RoleTable: FC<RoleTableProps> = ({
</TableRow>
</TableHeader>
<TableBody>
<ChooseOne>
<Cond condition={isLoading}>
<TableLoader />
</Cond>
<Cond condition={isEmpty}>
<TableRow className="h-14">
<TableCell colSpan={999}>
<EmptyState
message="No custom roles yet"
description={
canCreateOrgRole && isCustomRolesEnabled
? "Create your first custom role"
: !isCustomRolesEnabled
? "Upgrade to a premium license to create a custom role"
: "You don't have permission to create a custom role"
}
cta={
canCreateOrgRole &&
isCustomRolesEnabled && (
<Button asChild>
<RouterLink to="create">
<PlusIcon />
Create custom role
</RouterLink>
</Button>
)
}
/>
</TableCell>
</TableRow>
</Cond>
<Cond>
{[...(roles ?? [])]
.sort((a, b) => a.name.localeCompare(b.name))
.map((role) => (
<RoleRow
key={role.name}
role={role}
canUpdateOrgRole={canUpdateOrgRole}
canDeleteOrgRole={canDeleteOrgRole}
onDelete={() => onDeleteRole(role)}
/>
))}
</Cond>
</ChooseOne>
<RoleTableBody
roles={roles}
isCustomRolesEnabled={isCustomRolesEnabled}
canCreateOrgRole={canCreateOrgRole}
canUpdateOrgRole={canUpdateOrgRole}
canDeleteOrgRole={canDeleteOrgRole}
onDeleteRole={onDeleteRole}
/>
</TableBody>
</Table>
);
};
const RoleTableBody: FC<RoleTableProps> = ({
roles,
isCustomRolesEnabled,
canCreateOrgRole,
canUpdateOrgRole,
canDeleteOrgRole,
onDeleteRole,
}) => {
if (roles === undefined) {
return <TableLoader />;
}
if (roles.length === 0) {
return (
<TableRow className="h-14">
<TableCell colSpan={999}>
<EmptyState
message="No custom roles yet"
description={
canCreateOrgRole && isCustomRolesEnabled
? "Create your first custom role"
: !isCustomRolesEnabled
? "Upgrade to a premium license to create a custom role"
: "You don't have permission to create a custom role"
}
cta={
canCreateOrgRole &&
isCustomRolesEnabled && (
<Button asChild>
<RouterLink to="create">
<PlusIcon />
Create custom role
</RouterLink>
</Button>
)
}
/>
</TableCell>
</TableRow>
);
}
return (
<>
{[...roles]
.sort((a, b) => a.name.localeCompare(b.name))
.map((role) => (
<RoleRow
key={role.name}
role={role}
canUpdateOrgRole={canUpdateOrgRole}
canDeleteOrgRole={canDeleteOrgRole}
onDelete={() => onDeleteRole(role)}
/>
))}
</>
);
};
interface RoleRowProps {
role: AssignableRoles;
canUpdateOrgRole: boolean;
@@ -1,5 +1,4 @@
import type { FC } from "react";
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
import { EmptyState } from "#/components/EmptyState/EmptyState";
import { Link } from "#/components/Link/Link";
import {
@@ -37,28 +36,27 @@ export const IdpMappingTable: FC<IdpMappingTableProps> = ({
</TableRow>
</TableHeader>
<TableBody>
<ChooseOne>
<Cond condition={rowCount === 0}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message={`No ${type.toLocaleLowerCase()} mappings`}
isCompact
cta={
<Link
href={docs(
`/admin/users/idp-sync#${type.toLocaleLowerCase()}-sync`,
)}
>
How to setup IdP {type.toLocaleLowerCase()} sync
</Link>
}
/>
</TableCell>
</TableRow>
</Cond>
<Cond>{children}</Cond>
</ChooseOne>
{rowCount === 0 ? (
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message={`No ${type.toLocaleLowerCase()} mappings`}
isCompact
cta={
<Link
href={docs(
`/admin/users/idp-sync#${type.toLocaleLowerCase()}-sync`,
)}
>
How to setup IdP {type.toLocaleLowerCase()} sync
</Link>
}
/>
</TableCell>
</TableRow>
) : (
children
)}
</TableBody>
</Table>
<div className="flex justify-end">
@@ -12,7 +12,6 @@ import {
roleIdpSyncSettings,
} from "#/api/queries/organizations";
import { organizationRoles } from "#/api/queries/roles";
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
import { EmptyState } from "#/components/EmptyState/EmptyState";
import { Link } from "#/components/Link/Link";
import { PaywallPremium } from "#/components/Paywall/PaywallPremium";
@@ -128,61 +127,57 @@ const IdpSyncPage: FC = () => {
</p>
</div>
</header>
<ChooseOne>
<Cond condition={!isIdpSyncEnabled}>
<PaywallPremium
message="IdP Sync"
description="Configure group and role mappings to manage permissions outside of Coder. You need a Premium license to use this feature."
documentationLink={docs("/admin/users/idp-sync")}
/>
</Cond>
<Cond>
<IdpSyncPageView
tab={tab}
groupSyncSettings={groupIdpSyncSettingsQuery.data}
roleSyncSettings={roleIdpSyncSettingsQuery.data}
claimFieldValues={fieldValuesQuery.data}
groups={groupsQuery.data}
groupsMap={groupsMap}
roles={rolesQuery.data}
organization={organization}
onGroupSyncFieldChange={setGroupField}
onRoleSyncFieldChange={setRoleField}
error={error}
onSubmitGroupSyncSettings={async (data) => {
const mutation =
patchGroupSyncSettingsMutation.mutateAsync(data);
toast.promise(mutation, {
loading: "Updating IdP group sync settings...",
success: "IdP group sync settings updated.",
error: (error) => ({
message: getErrorMessage(
error,
"Failed to update IdP group sync settings.",
),
{!isIdpSyncEnabled ? (
<PaywallPremium
message="IdP Sync"
description="Configure group and role mappings to manage permissions outside of Coder. You need a Premium license to use this feature."
documentationLink={docs("/admin/users/idp-sync")}
/>
) : (
<IdpSyncPageView
tab={tab}
groupSyncSettings={groupIdpSyncSettingsQuery.data}
roleSyncSettings={roleIdpSyncSettingsQuery.data}
claimFieldValues={fieldValuesQuery.data}
groups={groupsQuery.data}
groupsMap={groupsMap}
roles={rolesQuery.data}
organization={organization}
onGroupSyncFieldChange={setGroupField}
onRoleSyncFieldChange={setRoleField}
error={error}
onSubmitGroupSyncSettings={async (data) => {
const mutation = patchGroupSyncSettingsMutation.mutateAsync(data);
toast.promise(mutation, {
loading: "Updating IdP group sync settings...",
success: "IdP group sync settings updated.",
error: (error) => ({
message: getErrorMessage(
error,
"Failed to update IdP group sync settings.",
),
description: getErrorDetail(error),
}),
});
}}
onSubmitRoleSyncSettings={async (data) => {
try {
await patchRoleSyncSettingsMutation.mutateAsync(data);
toast.success("IdP Role sync settings updated.");
} catch (error) {
toast.error(
getErrorMessage(
error,
"Failed to update IdP role sync settings.",
),
{
description: getErrorDetail(error),
}),
});
}}
onSubmitRoleSyncSettings={async (data) => {
try {
await patchRoleSyncSettingsMutation.mutateAsync(data);
toast.success("IdP Role sync settings updated.");
} catch (error) {
toast.error(
getErrorMessage(
error,
"Failed to update IdP role sync settings.",
),
{
description: getErrorDetail(error),
},
);
}
}}
/>
</Cond>
</ChooseOne>
},
);
}
}}
/>
)}
</div>
</div>
);
@@ -11,7 +11,6 @@ import type {
import { Avatar } from "#/components/Avatar/Avatar";
import { AvatarData } from "#/components/Avatar/AvatarData";
import { Button } from "#/components/Button/Button";
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
import {
DropdownMenu,
DropdownMenuContent,
@@ -225,12 +224,6 @@ export const TemplatePermissionsPageView: FC<
onUpdateGroup,
onRemoveGroup,
}) => {
const isEmpty = Boolean(
templateACL &&
templateACL.users.length === 0 &&
templateACL.group.length === 0,
);
return (
<>
<PageHeader className="pt-0">
@@ -259,137 +252,170 @@ export const TemplatePermissionsPageView: FC<
</TableRow>
</TableHeader>
<TableBody>
<ChooseOne>
<Cond condition={!templateACL}>
<TableLoader />
</Cond>
<Cond condition={isEmpty}>
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message="No members yet"
description="Add a member using the controls above"
/>
</TableCell>
</TableRow>
</Cond>
<Cond>
{templateACL?.group.map((group) => (
<TableRow key={group.id}>
<TableCell>
<AvatarData
avatar={
<Avatar
size="lg"
fallback={group.display_name || group.name}
src={group.avatar_url}
/>
}
title={group.display_name || group.name}
subtitle={getGroupSubtitle(group)}
/>
</TableCell>
<TableCell>
<ChooseOne>
<Cond condition={canUpdatePermissions}>
<RoleSelect
value={group.role}
disabled={updatingGroupId === group.id}
onValueChange={(role) => {
onUpdateGroup(group, role);
}}
/>
</Cond>
<Cond>
<div className="capitalize">{group.role}</div>
</Cond>
</ChooseOne>
</TableCell>
<TableCell>
{canUpdatePermissions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon-lg"
variant="subtle"
aria-label="Open menu"
>
<EllipsisVerticalIcon aria-hidden="true" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={() => onRemoveGroup(group)}
>
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
))}
{templateACL?.users.map((user) => (
<TableRow key={user.id}>
<TableCell>
<AvatarData
title={user.username}
subtitle={user.email}
src={user.avatar_url}
/>
</TableCell>
<TableCell>
<ChooseOne>
<Cond condition={canUpdatePermissions}>
<RoleSelect
value={user.role}
disabled={updatingUserId === user.id}
onValueChange={(role) => {
onUpdateUser(user, role);
}}
/>
</Cond>
<Cond>
<div className="capitalize">{user.role}</div>
</Cond>
</ChooseOne>
</TableCell>
<TableCell>
{canUpdatePermissions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon-lg"
variant="subtle"
aria-label="Open menu"
>
<EllipsisVerticalIcon aria-hidden="true" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={() => onRemoveUser(user)}
>
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
))}
</Cond>
</ChooseOne>
<MembersTableBody
templateACL={templateACL}
canUpdatePermissions={canUpdatePermissions}
updatingUserId={updatingUserId}
updatingGroupId={updatingGroupId}
onUpdateUser={onUpdateUser}
onRemoveUser={onRemoveUser}
onUpdateGroup={onUpdateGroup}
onRemoveGroup={onRemoveGroup}
/>
</TableBody>
</Table>
</div>
</>
);
};
interface MembersTableBodyProps {
templateACL: TemplateACL | undefined;
canUpdatePermissions: boolean;
updatingUserId: TemplateUser["id"] | undefined;
updatingGroupId: TemplateGroup["id"] | undefined;
onUpdateUser: (user: TemplateUser, role: TemplateRole) => void;
onRemoveUser: (user: TemplateUser) => void;
onUpdateGroup: (group: TemplateGroup, role: TemplateRole) => void;
onRemoveGroup: (group: Group) => void;
}
const MembersTableBody: FC<MembersTableBodyProps> = ({
templateACL,
canUpdatePermissions,
updatingUserId,
updatingGroupId,
onUpdateUser,
onRemoveUser,
onUpdateGroup,
onRemoveGroup,
}) => {
if (!templateACL) {
return <TableLoader />;
}
const isEmpty =
templateACL.users.length === 0 && templateACL.group.length === 0;
if (isEmpty) {
return (
<TableRow>
<TableCell colSpan={999}>
<EmptyState
message="No members yet"
description="Add a member using the controls above"
/>
</TableCell>
</TableRow>
);
}
return (
<>
{templateACL.group.map((group) => (
<TableRow key={group.id}>
<TableCell>
<AvatarData
avatar={
<Avatar
size="lg"
fallback={group.display_name || group.name}
src={group.avatar_url}
/>
}
title={group.display_name || group.name}
subtitle={getGroupSubtitle(group)}
/>
</TableCell>
<TableCell>
{canUpdatePermissions ? (
<RoleSelect
value={group.role}
disabled={updatingGroupId === group.id}
onValueChange={(role) => {
onUpdateGroup(group, role);
}}
/>
) : (
<div className="capitalize">{group.role}</div>
)}
</TableCell>
<TableCell>
{canUpdatePermissions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon-lg"
variant="subtle"
aria-label="Open menu"
>
<EllipsisVerticalIcon aria-hidden="true" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={() => onRemoveGroup(group)}
>
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
))}
{templateACL.users.map((user) => (
<TableRow key={user.id}>
<TableCell>
<AvatarData
title={user.username}
subtitle={user.email}
src={user.avatar_url}
/>
</TableCell>
<TableCell>
{canUpdatePermissions ? (
<RoleSelect
value={user.role}
disabled={updatingUserId === user.id}
onValueChange={(role) => {
onUpdateUser(user, role);
}}
/>
) : (
<div className="capitalize">{user.role}</div>
)}
</TableCell>
<TableCell>
{canUpdatePermissions && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon-lg"
variant="subtle"
aria-label="Open menu"
>
<EllipsisVerticalIcon aria-hidden="true" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-content-destructive focus:text-content-destructive"
onClick={() => onRemoveUser(user)}
>
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</TableCell>
</TableRow>
))}
</>
);
};
@@ -6,7 +6,6 @@ import type { FC, ReactNode } from "react";
import type { APIKeyWithOwner } from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Button } from "#/components/Button/Button";
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
import {
Table,
TableBody,
@@ -44,8 +43,6 @@ export const TokensPageView: FC<TokensPageViewProps> = ({
onDelete,
deleteTokenError,
}) => {
const theme = useTheme();
return (
<div className="flex flex-col gap-4">
{Boolean(getTokensError) && <ErrorAlert error={getTokensError} />}
@@ -63,71 +60,88 @@ export const TokensPageView: FC<TokensPageViewProps> = ({
</TableRow>
</TableHeader>
<TableBody>
<ChooseOne>
<Cond condition={isLoading}>
<TableLoader />
</Cond>
<Cond condition={hasLoaded && (!tokens || tokens.length === 0)}>
<TableEmpty message="No tokens found" />
</Cond>
<Cond>
{tokens?.map((token) => {
return (
<TableRow
key={token.id}
data-testid={`token-${token.id}`}
tabIndex={0}
>
<TableCell>
<span style={{ color: theme.palette.text.secondary }}>
{token.id}
</span>
</TableCell>
<TableCell>
<span style={{ color: theme.palette.text.secondary }}>
{token.token_name}
</span>
</TableCell>
<TableCell>{lastUsedOrNever(token.last_used)}</TableCell>
<TableCell>
<span
style={{ color: theme.palette.text.secondary }}
data-chromatic="ignore"
>
{dayjs(token.expires_at).fromNow()}
</span>
</TableCell>
<TableCell>
<span style={{ color: theme.palette.text.secondary }}>
{dayjs(token.created_at).fromNow()}
</span>
</TableCell>
<TableCell>
<span style={{ color: theme.palette.text.secondary }}>
<Button
onClick={() => {
onDelete(token);
}}
size="icon"
variant="destructive"
aria-label="Delete token"
>
<TrashIcon className="size-icon-sm" />
</Button>
</span>
</TableCell>
</TableRow>
);
})}
</Cond>
</ChooseOne>
<TokensTableBody
tokens={tokens}
isLoading={isLoading}
hasLoaded={hasLoaded}
onDelete={onDelete}
/>
</TableBody>
</Table>
</div>
);
};
interface TokensTableBodyProps {
tokens?: APIKeyWithOwner[];
isLoading: boolean;
hasLoaded: boolean;
onDelete: (token: APIKeyWithOwner) => void;
}
const TokensTableBody: FC<TokensTableBodyProps> = ({
tokens,
isLoading,
hasLoaded,
onDelete,
}) => {
const theme = useTheme();
if (isLoading) {
return <TableLoader />;
}
if (hasLoaded && (!tokens || tokens.length === 0)) {
return <TableEmpty message="No tokens found" />;
}
return (
<>
{tokens?.map((token) => (
<TableRow key={token.id} data-testid={`token-${token.id}`} tabIndex={0}>
<TableCell>
<span style={{ color: theme.palette.text.secondary }}>
{token.id}
</span>
</TableCell>
<TableCell>
<span style={{ color: theme.palette.text.secondary }}>
{token.token_name}
</span>
</TableCell>
<TableCell>{lastUsedOrNever(token.last_used)}</TableCell>
<TableCell>
<span
style={{ color: theme.palette.text.secondary }}
data-chromatic="ignore"
>
{dayjs(token.expires_at).fromNow()}
</span>
</TableCell>
<TableCell>
<span style={{ color: theme.palette.text.secondary }}>
{dayjs(token.created_at).fromNow()}
</span>
</TableCell>
<TableCell>
<span style={{ color: theme.palette.text.secondary }}>
<Button
onClick={() => {
onDelete(token);
}}
size="icon"
variant="destructive"
aria-label="Delete token"
>
<TrashIcon className="size-icon-sm" />
</Button>
</span>
</TableCell>
</TableRow>
))}
</>
);
};
@@ -1,7 +1,6 @@
import type { FC } from "react";
import type { Region } from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { ChooseOne, Cond } from "#/components/Conditionals/ChooseOne";
import {
SettingsHeader,
SettingsHeaderDescription,
@@ -61,25 +60,46 @@ export const WorkspaceProxyView: FC<WorkspaceProxyViewProps> = ({
</TableRow>
</TableHeader>
<TableBody>
<ChooseOne>
<Cond condition={isLoading}>
<TableLoader />
</Cond>
<Cond condition={hasLoaded && proxies?.length === 0}>
<TableEmpty message="No workspace proxies found" />
</Cond>
<Cond>
{proxies?.map((proxy) => (
<ProxyRow
latency={proxyLatencies?.[proxy.id]}
key={proxy.id}
proxy={proxy}
/>
))}
</Cond>
</ChooseOne>
<ProxiesTableBody
proxies={proxies}
proxyLatencies={proxyLatencies}
isLoading={isLoading}
hasLoaded={hasLoaded}
/>
</TableBody>
</Table>
</div>
);
};
interface ProxiesTableBodyProps {
proxies?: readonly Region[];
proxyLatencies?: Record<string, ProxyLatencyReport>;
isLoading: boolean;
hasLoaded: boolean;
}
const ProxiesTableBody: FC<ProxiesTableBodyProps> = ({
proxies,
proxyLatencies,
isLoading,
hasLoaded,
}) => {
if (isLoading) {
return <TableLoader />;
}
if (hasLoaded && proxies?.length === 0) {
return <TableEmpty message="No workspace proxies found" />;
}
return (
<>
{proxies?.map((proxy) => (
<ProxyRow
latency={proxyLatencies?.[proxy.id]}
key={proxy.id}
proxy={proxy}
/>
))}
</>
);
};