Skip to content

feat: improve custom roles create/edit page #14456

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Aug 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react";
import { expect, userEvent, within } from "@storybook/test";
import {
MockRole2WithOrgPermissions,
MockRoleWithOrgPermissions,
assignableRole,
mockApiError,
Expand All @@ -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),
Expand Down Expand Up @@ -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();
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand All @@ -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,
Expand All @@ -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 },
Expand All @@ -194,7 +199,7 @@ const ActionCheckboxes: FC<ActionCheckboxesProps> = ({

const newPermissions = checked
? [
...(checkedActions ?? []),
...checkedActions,
{
negate: false,
resource_type: resource_type as RBACResource,
Expand All @@ -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>
Expand All @@ -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>
Expand All @@ -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>>;
Expand All @@ -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>
}
/>
);
};
Expand All @@ -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 changes: 15 additions & 0 deletions site/src/testHelpers/entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,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(
Expand Down
Loading