Skip to content
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