feat: improve custom roles create/edit page (#14456)

* fix: improve show/hide checkbox text

* feat: add parent checkbox for grouped resource permissions

* fix: align action list item to a grid

* chore: add additional tests

* fix: format
This commit is contained in:
Jaayden Halko
2024-08-28 18:13:33 -04:00
committed by GitHub
parent f3ea740b27
commit 15d74a11a0
3 changed files with 177 additions and 41 deletions
@@ -1,5 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react";
import { expect, userEvent, within } from "@storybook/test";
import {
MockRole2WithOrgPermissions,
MockRoleWithOrgPermissions,
assignableRole,
mockApiError,
@@ -25,6 +27,16 @@ export const Default: Story = {
},
};
export const CheckboxIndeterminate: Story = {
args: {
role: assignableRole(MockRole2WithOrgPermissions, true),
onSubmit: () => null,
isLoading: false,
organizationName: "my-org",
canAssignOrgRole: true,
},
};
export const WithError: Story = {
args: {
role: assignableRole(MockRoleWithOrgPermissions, true),
@@ -61,3 +73,17 @@ export const ShowAllResources: Story = {
allResources: true,
},
};
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];
await user.click(checkbox);
await expect(checkbox).toBeChecked();
await user.click(checkbox);
await expect(checkbox).not.toBeChecked();
},
};
@@ -146,12 +146,6 @@ export const CreateEditRolePageView: FC<CreateEditRolePageViewProps> = ({
);
};
interface ActionCheckboxesProps {
permissions: readonly Permission[] | undefined;
form: ReturnType<typeof useFormik<Role>> & { values: Role };
allResources: boolean;
}
const ResourceActionComparator = (
p: Permission,
resource: string,
@@ -160,6 +154,7 @@ const ResourceActionComparator = (
p.resource_type === resource &&
(p.action.toString() === "*" || p.action === action);
// the subset of resources that are useful for most users
const DEFAULT_RESOURCES = [
"audit_log",
"group",
@@ -177,6 +172,12 @@ const filteredRBACResourceActions = Object.fromEntries(
),
);
interface ActionCheckboxesProps {
permissions: readonly Permission[];
form: ReturnType<typeof useFormik<Role>> & { values: Role };
allResources: boolean;
}
const ActionCheckboxes: FC<ActionCheckboxesProps> = ({
permissions,
form,
@@ -185,6 +186,10 @@ const ActionCheckboxes: FC<ActionCheckboxesProps> = ({
const [checkedActions, setCheckActions] = useState(permissions);
const [showAllResources, setShowAllResources] = useState(allResources);
const resourceActions = showAllResources
? RBACResourceActions
: filteredRBACResourceActions;
const handleActionCheckChange = async (
e: ChangeEvent<HTMLInputElement>,
form: ReturnType<typeof useFormik<Role>> & { values: Role },
@@ -194,7 +199,7 @@ const ActionCheckboxes: FC<ActionCheckboxesProps> = ({
const newPermissions = checked
? [
...(checkedActions ?? []),
...checkedActions,
{
negate: false,
resource_type: resource_type as RBACResource,
@@ -209,9 +214,36 @@ const ActionCheckboxes: FC<ActionCheckboxesProps> = ({
await form.setFieldValue("organization_permissions", newPermissions);
};
const resourceActions = showAllResources
? RBACResourceActions
: filteredRBACResourceActions;
const handleResourceCheckChange = async (
e: ChangeEvent<HTMLInputElement>,
form: ReturnType<typeof useFormik<Role>> & { values: Role },
indeterminate: boolean,
) => {
const { name, checked } = e.currentTarget;
const resource = name as RBACResource;
const resourceActionsForResource = resourceActions[resource] || {};
const newCheckedActions =
!checked || indeterminate
? checkedActions?.filter((p) => p.resource_type !== resource)
: checkedActions;
const newPermissions =
checked || indeterminate
? [
...newCheckedActions,
...Object.keys(resourceActionsForResource).map((resourceKey) => ({
negate: false,
resource_type: resource as RBACResource,
action: resourceKey as RBACAction,
})),
]
: [...newCheckedActions];
setCheckActions(newPermissions);
await form.setFieldValue("organization_permissions", newPermissions);
};
return (
<TableContainer>
@@ -233,36 +265,17 @@ const ActionCheckboxes: FC<ActionCheckboxesProps> = ({
<TableBody>
{Object.entries(resourceActions).map(([resourceKey, value]) => {
return (
<TableRow key={resourceKey}>
<TableCell sx={{ paddingLeft: 2 }} colSpan={2}>
<li key={resourceKey} css={styles.checkBoxes}>
{resourceKey}
<ul css={styles.checkBoxes}>
{Object.entries(value).map(([actionKey, value]) => (
<li key={actionKey}>
<span css={styles.actionText}>
<Checkbox
size="small"
name={`${resourceKey}:${actionKey}`}
checked={checkedActions?.some((p) =>
ResourceActionComparator(
p,
resourceKey,
actionKey,
),
)}
onChange={(e) => handleActionCheckChange(e, form)}
/>
{actionKey}
</span>{" "}
&ndash;{" "}
<span css={styles.actionDescription}>{value}</span>
</li>
))}
</ul>
</li>
</TableCell>
</TableRow>
<PermissionCheckboxGroup
key={resourceKey}
checkedActions={checkedActions?.filter(
(a) => a.resource_type === resourceKey,
)}
resourceKey={resourceKey}
value={value}
form={form}
handleActionCheckChange={handleActionCheckChange}
handleResourceCheckChange={handleResourceCheckChange}
/>
);
})}
</TableBody>
@@ -285,6 +298,77 @@ const ActionCheckboxes: FC<ActionCheckboxesProps> = ({
);
};
interface PermissionCheckboxGroupProps {
checkedActions: readonly Permission[];
resourceKey: string;
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>;
handleResourceCheckChange: (
e: ChangeEvent<HTMLInputElement>,
form: ReturnType<typeof useFormik<Role>> & { values: Role },
indeterminate: boolean,
) => Promise<void>;
}
const PermissionCheckboxGroup: FC<PermissionCheckboxGroupProps> = ({
checkedActions,
resourceKey,
value,
form,
handleActionCheckChange,
handleResourceCheckChange,
}) => {
return (
<TableRow key={resourceKey}>
<TableCell sx={{ paddingLeft: 2 }} colSpan={2}>
<li key={resourceKey} css={styles.checkBoxes}>
<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 css={styles.checkBoxes}>
{Object.entries(value).map(([actionKey, value]) => (
<li key={actionKey} css={styles.actionItem}>
<span css={styles.actionText}>
<Checkbox
size="small"
name={`${resourceKey}:${actionKey}`}
checked={checkedActions.some((p) =>
ResourceActionComparator(p, resourceKey, actionKey),
)}
onChange={(e) => handleActionCheckChange(e, form)}
/>
{actionKey}
</span>
<span css={styles.actionDescription}>{value}</span>
</li>
))}
</ul>
</li>
</TableCell>
</TableRow>
);
};
interface ShowAllResourcesCheckboxProps {
showAllResources: boolean;
setShowAllResources: React.Dispatch<React.SetStateAction<boolean>>;
@@ -308,7 +392,13 @@ const ShowAllResourcesCheckbox: FC<ShowAllResourcesCheckboxProps> = ({
icon={<VisibilityOffOutlinedIcon />}
/>
}
label={<span style={{ fontSize: 12 }}>Show all permissions</span>}
label={
<span style={{ fontSize: 12 }}>
{showAllResources
? "Hide advanced permissions"
: "Show advanced permissions"}
</span>
}
/>
);
};
@@ -323,7 +413,12 @@ const styles = {
}),
actionDescription: (theme) => ({
color: theme.palette.text.secondary,
paddingTop: 6,
}),
actionItem: {
display: "grid",
gridTemplateColumns: "270px 1fr",
},
} satisfies Record<string, Interpolation<Theme>>;
export default CreateEditRolePageView;
+15
View File
@@ -392,6 +392,21 @@ export const MockRoleWithOrgPermissions: TypesGen.Role = {
user_permissions: [],
};
export const MockRole2WithOrgPermissions: TypesGen.Role = {
name: "my-role-1",
display_name: "My Role 1",
organization_id: MockOrganization.id,
site_permissions: [],
organization_permissions: [
{
negate: false,
resource_type: "audit_log",
action: "create",
},
],
user_permissions: [],
};
// assignableRole takes a role and a boolean. The boolean implies if the
// actor can assign (add/remove) the role from other users.
export function assignableRole(