refactor(site): de-MUI custom role create/edit form (#27820)

Migrate the organization custom role create/edit form off MUI onto
shared site components.

- Replace `TextField` with `FormField` for name and display name
- Replace permission checkboxes with the shared shadcn `Checkbox`
(including indeterminate)
- Replace the “show advanced permissions” control with a `Switch` +
`Label`
- Update the toggle story to query by accessible checkbox role/name
This commit is contained in:
Jake Howell
2026-08-06 11:14:30 +10:00
committed by GitHub
parent 0d0c6e53ba
commit db4b6dc556
2 changed files with 174 additions and 136 deletions
@@ -105,12 +105,51 @@ export const ToggleParentCheckbox: Story = {
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
const checkbox = await canvas
.getByTestId("audit_log")
.getElementsByTagName("input")[0];
const checkbox = canvas.getByRole("checkbox", { name: "audit_log" });
await user.click(checkbox);
await expect(checkbox).toBeChecked();
await user.click(checkbox);
await expect(checkbox).not.toBeChecked();
},
};
export const ToggleAdvancedPermissions: Story = {
args: {
...Default.args,
role: undefined,
},
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
// Advanced-only resources are hidden until the switch is enabled.
expect(
canvas.queryByRole("checkbox", { name: "api_key" }),
).not.toBeInTheDocument();
const [toggle] = canvas.getAllByRole("switch", {
name: "Show advanced permissions",
});
await user.click(toggle);
// Enabling the switch reveals advanced resources and flips the label.
await expect(
canvas.getByRole("checkbox", { name: "api_key" }),
).toBeInTheDocument();
const [enabledToggle] = canvas.getAllByRole("switch", {
name: "Hide advanced permissions",
});
await expect(enabledToggle).toBeChecked();
await user.click(enabledToggle);
// Disabling the switch hides advanced resources again.
await expect(
canvas.queryByRole("checkbox", { name: "api_key" }),
).not.toBeInTheDocument();
const [disabledToggle] = canvas.getAllByRole("switch", {
name: "Show advanced permissions",
});
await expect(disabledToggle).not.toBeChecked();
},
};
@@ -1,9 +1,5 @@
import Checkbox from "@mui/material/Checkbox";
import FormControlLabel from "@mui/material/FormControlLabel";
import TextField from "@mui/material/TextField";
import { useFormik } from "formik";
import { EyeIcon, EyeOffIcon } from "lucide-react";
import { type ChangeEvent, type FC, useState } from "react";
import { type FC, useId, useState } from "react";
import { useNavigate } from "react-router";
import * as Yup from "yup";
import { isApiValidationError } from "#/api/errors";
@@ -18,18 +14,21 @@ import type {
} from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Button } from "#/components/Button/Button";
import { Checkbox } from "#/components/Checkbox/Checkbox";
import { FormFields, FormFooter, VerticalForm } from "#/components/Form/Form";
import { FormField } from "#/components/FormField/FormField";
import { Label } from "#/components/Label/Label";
import {
SettingsHeader,
SettingsHeaderDescription,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
import { Spinner } from "#/components/Spinner/Spinner";
import { Switch } from "#/components/Switch/Switch";
import {
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
@@ -114,22 +113,22 @@ const CreateEditRolePageView: FC<CreateEditRolePageViewProps> = ({
<ErrorAlert error={error} />
)}
<TextField
{...getFieldHelpers("name", {
<FormField
field={getFieldHelpers("name", {
helperText:
"The role name cannot be modified after the role is created.",
})}
autoFocus
fullWidth
disabled={role !== undefined}
label="Name"
autoFocus
disabled={role !== undefined}
className="w-full"
/>
<TextField
{...getFieldHelpers("display_name", {
<FormField
field={getFieldHelpers("display_name", {
helperText: "Optional: keep empty to default to the name.",
})}
fullWidth
label="Display Name"
className="w-full"
/>
<ActionCheckboxes
permissions={role?.organization_permissions || []}
@@ -179,6 +178,12 @@ const filteredRBACResourceActions = Object.fromEntries(
),
);
// Object.entries widens keys to `string`; this narrows them back to the
// RBACResource union without an assertion.
function isRBACResource(resource: string): resource is RBACResource {
return resource in RBACResourceActions;
}
interface ActionCheckboxesProps {
permissions: readonly Permission[];
form: ReturnType<typeof useFormik<Role>> & { values: Role };
@@ -197,11 +202,7 @@ const ActionCheckboxes: FC<ActionCheckboxesProps> = ({
? RBACResourceActions
: filteredRBACResourceActions;
const handleActionCheckChange = async (
e: ChangeEvent<HTMLInputElement>,
form: ReturnType<typeof useFormik<Role>> & { values: Role },
) => {
const { name, checked } = e.currentTarget;
const handleActionCheckChange = async (name: string, checked: boolean) => {
const [resource_type, action] = name.split(":");
const newPermissions = checked
@@ -222,13 +223,10 @@ const ActionCheckboxes: FC<ActionCheckboxesProps> = ({
};
const handleResourceCheckChange = async (
e: ChangeEvent<HTMLInputElement>,
form: ReturnType<typeof useFormik<Role>> & { values: Role },
resource: RBACResource,
checked: boolean,
indeterminate: boolean,
) => {
const { name, checked } = e.currentTarget;
const resource = name as RBACResource;
const resourceActionsForResource = resourceActions[resource] || {};
const newCheckedActions =
@@ -253,61 +251,55 @@ const ActionCheckboxes: FC<ActionCheckboxesProps> = ({
};
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Permission</TableHead>
<TableHead className="py-1 text-right">
<ShowAllResourcesCheckbox
showAllResources={showAllResources}
setShowAllResources={setShowAllResources}
/>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Object.entries(resourceActions).map(([resourceKey, value]) => {
return (
<PermissionCheckboxGroup
key={resourceKey}
checkedActions={checkedActions?.filter(
(a) => a.resource_type === resourceKey,
)}
resourceKey={resourceKey}
value={value}
form={form}
handleActionCheckChange={handleActionCheckChange}
handleResourceCheckChange={handleResourceCheckChange}
/>
);
})}
</TableBody>
<TableFooter>
<TableRow>
<TableCell align="right" colSpan={2} className="py-1 pr-1">
<ShowAllResourcesCheckbox
showAllResources={showAllResources}
setShowAllResources={setShowAllResources}
/>
</TableCell>
</TableRow>
</TableFooter>
</Table>
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Permission</TableHead>
<TableHead className="py-1 text-right">
<ShowAllResourcesSwitch
showAllResources={showAllResources}
setShowAllResources={setShowAllResources}
/>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Object.entries(resourceActions).map(([resourceKey, value]) => {
if (!isRBACResource(resourceKey)) {
return null;
}
return (
<PermissionCheckboxGroup
key={resourceKey}
checkedActions={checkedActions?.filter(
(a) => a.resource_type === resourceKey,
)}
resourceKey={resourceKey}
value={value}
handleActionCheckChange={handleActionCheckChange}
handleResourceCheckChange={handleResourceCheckChange}
/>
);
})}
</TableBody>
</Table>
<ShowAllResourcesSwitch
showAllResources={showAllResources}
setShowAllResources={setShowAllResources}
/>
</>
);
};
interface PermissionCheckboxGroupProps {
checkedActions: readonly Permission[];
resourceKey: string;
resourceKey: RBACResource;
value: Partial<Record<RBACAction, string>>;
form: ReturnType<typeof useFormik<Role>> & { values: Role };
handleActionCheckChange: (
e: ChangeEvent<HTMLInputElement>,
form: ReturnType<typeof useFormik<Role>> & { values: Role },
) => Promise<void>;
handleActionCheckChange: (name: string, checked: boolean) => Promise<void>;
handleResourceCheckChange: (
e: ChangeEvent<HTMLInputElement>,
form: ReturnType<typeof useFormik<Role>> & { values: Role },
resource: RBACResource,
checked: boolean,
indeterminate: boolean,
) => Promise<void>;
}
@@ -316,50 +308,62 @@ const PermissionCheckboxGroup: FC<PermissionCheckboxGroupProps> = ({
checkedActions,
resourceKey,
value,
form,
handleActionCheckChange,
handleResourceCheckChange,
}) => {
const actionCount = Object.keys(value).length;
const isResourceChecked = checkedActions.length === actionCount;
const isResourceIndeterminate =
checkedActions.length > 0 && checkedActions.length < actionCount;
return (
<TableRow key={resourceKey}>
<TableCell className="pl-0.5" colSpan={2}>
<TableCell className="px-4" colSpan={2}>
<li key={resourceKey} className="m-0 list-none">
<Checkbox
size="small"
name={`${resourceKey}`}
checked={checkedActions.length === Object.keys(value).length}
indeterminate={
checkedActions.length > 0 &&
checkedActions.length < Object.keys(value).length
}
data-testid={`${resourceKey}`}
onChange={(e) =>
handleResourceCheckChange(
e,
form,
checkedActions.length > 0 &&
checkedActions.length < Object.keys(value).length,
)
}
/>
{resourceKey}
<ul className="m-0 list-none">
{Object.entries(value).map(([actionKey, value]) => (
<li key={actionKey} className="grid grid-cols-[270px_1fr]">
<span className="text-content-primary">
<Checkbox
size="small"
name={`${resourceKey}:${actionKey}`}
checked={checkedActions.some((p) =>
ResourceActionComparator(p, resourceKey, actionKey),
)}
onChange={(e) => handleActionCheckChange(e, form)}
/>
{actionKey}
</span>
<span className="pt-1.5 text-content-secondary">{value}</span>
</li>
))}
<div className="inline-flex items-center gap-2">
<Checkbox
name={resourceKey}
checked={
isResourceIndeterminate ? "indeterminate" : isResourceChecked
}
data-testid={resourceKey}
aria-label={resourceKey}
onCheckedChange={(checked) =>
handleResourceCheckChange(
resourceKey,
checked === true,
isResourceIndeterminate,
)
}
/>
<span>{resourceKey}</span>
</div>
<ul className="m-0 list-none py-2 flex flex-col gap-2 pl-8">
{Object.entries(value).map(([actionKey, description]) => {
const actionName = `${resourceKey}:${actionKey}`;
const isActionChecked = checkedActions.some((p) =>
ResourceActionComparator(p, resourceKey, actionKey),
);
return (
<li key={actionKey} className="grid grid-cols-[270px_1fr]">
<span className="inline-flex items-center text-content-primary gap-2">
<Checkbox
name={actionName}
checked={isActionChecked}
aria-label={actionName}
onCheckedChange={(checked) =>
handleActionCheckChange(actionName, checked === true)
}
/>
{actionKey}
</span>
<span className="pt-1.5 text-content-secondary">
{description}
</span>
</li>
);
})}
</ul>
</li>
</TableCell>
@@ -367,37 +371,32 @@ const PermissionCheckboxGroup: FC<PermissionCheckboxGroupProps> = ({
);
};
interface ShowAllResourcesCheckboxProps {
interface ShowAllResourcesSwitchProps {
showAllResources: boolean;
setShowAllResources: React.Dispatch<React.SetStateAction<boolean>>;
}
const ShowAllResourcesCheckbox: FC<ShowAllResourcesCheckboxProps> = ({
const ShowAllResourcesSwitch: FC<ShowAllResourcesSwitchProps> = ({
showAllResources,
setShowAllResources,
}) => {
const id = useId();
return (
<FormControlLabel
sx={{ marginRight: 1 }}
control={
<Checkbox
size="small"
id="show_all_permissions"
name="show_all_permissions"
checked={showAllResources}
onChange={(e) => setShowAllResources(e.currentTarget.checked)}
checkedIcon={<EyeIcon className="size-icon-sm" />}
icon={<EyeOffIcon className="size-icon-sm" />}
/>
}
label={
<span style={{ fontSize: 12 }}>
{showAllResources
? "Hide advanced permissions"
: "Show advanced permissions"}
</span>
}
/>
<div className="mr-2 inline-flex items-center justify-end gap-2">
<Label htmlFor={id} className="cursor-pointer text-xs font-normal">
{showAllResources
? "Hide advanced permissions"
: "Show advanced permissions"}
</Label>
<Switch
id={id}
size="sm"
name="show_all_permissions"
checked={showAllResources}
onCheckedChange={setShowAllResources}
/>
</div>
);
};