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
15 changes: 14 additions & 1 deletion resources/js/components/roles/PermissionTree.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<div :style="`padding-inline-start: ${12 * depth - 12}px`">
<template v-for="permission in permissions" :key="permission.value">
<template v-for="permission in visiblePermissions" :key="permission.value">
<ui-checkbox
:class="[
permission.description
Expand All @@ -18,16 +18,23 @@
v-if="permission.children.length"
:depth="depth + 1"
:initial-permissions="permission.children"
:checked-permissions="checkedPermissions"
/>
</template>
</div>
</template>

<script>
import { visible } from '@/components/roles/permissions.js';

export default {
props: {
initialPermissions: Array,
depth: Number,
checkedPermissions: {
type: Array,
default: () => [],
},
},

data() {
Expand All @@ -36,6 +43,12 @@ export default {
};
},

computed: {
visiblePermissions() {
return visible(this.permissions, this.checkedPermissions);
},
},

watch: {
initialPermissions: {
handler(newPermissions) {
Expand Down
58 changes: 28 additions & 30 deletions resources/js/components/roles/PublishForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@
</Button>
</template>
<Card>
<PermissionTree :depth="1" :initial-permissions="group.permissions" />
<PermissionTree
:depth="1"
:initial-permissions="group.permissions"
:checked-permissions="checkedPermissions"
/>
</Card>
</Panel>
</div>
Expand All @@ -78,6 +82,7 @@
import { Header, Button, Panel, PanelHeader, Heading, Card, Switch, Field, Input, CommandPaletteItem } from '@/components/ui';
import { requireElevatedSession } from '@/components/elevated-sessions';
import PermissionTree from '@/components/roles/PermissionTree.vue';
import { allVisibleChecked, checkVisible, uncheckAll, visible } from '@/components/roles/permissions.js';
import { router } from '@inertiajs/vue3';

const checked = function (permissions) {
Expand Down Expand Up @@ -159,46 +164,39 @@ export default {

methods: {
areAllChecked(group) {
const checkAll = (permissions) => {
return permissions.every(permission => {
const childrenChecked = permission.children && permission.children.length
? checkAll(permission.children)
: true;
return permission.checked && childrenChecked;
});
};
return checkAll(group.permissions);
const checkedPermissions = this.checkedPermissions;
const shown = visible(group.permissions, checkedPermissions);

return shown.length > 0 && allVisibleChecked(shown, checkedPermissions);
},

areAllCheckedInAllGroups() {
return this.permissions.every(group => this.areAllChecked(group));
},

// Checking only affects what the user can see, but unchecking clears the group
// outright. Unchecking a broader permission reveals the ones it was hiding, so
// leaving those checked would need a second click to empty the group.
toggleAllInGroup(group) {
const allChecked = this.areAllChecked(group);
const toggle = (permissions, checked) => {
permissions.forEach(permission => {
permission.checked = checked;
if (permission.children && permission.children.length) {
toggle(permission.children, checked);
}
});
};
toggle(group.permissions, !allChecked);
const checkedPermissions = this.checkedPermissions;

if (this.areAllChecked(group)) {
uncheckAll(group.permissions);
} else {
checkVisible(group.permissions, checkedPermissions);
}
},

toggleAllInAllGroups() {
const checkedPermissions = this.checkedPermissions;
const allChecked = this.areAllCheckedInAllGroups();
this.permissions.forEach(group => {
const toggle = (permissions, checked) => {
permissions.forEach(permission => {
permission.checked = checked;
if (permission.children && permission.children.length) {
toggle(permission.children, checked);
}
});
};
toggle(group.permissions, !allChecked);

this.permissions.forEach((group) => {
if (allChecked) {
uncheckAll(group.permissions);
} else {
checkVisible(group.permissions, checkedPermissions);
}
});
},

Expand Down
29 changes: 29 additions & 0 deletions resources/js/components/roles/permissions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export function isVisible(permission, checkedPermissions) {
return !permission.hidden_by.some((value) => checkedPermissions.includes(value));
}

export function visible(permissions, checkedPermissions) {
return permissions.filter((permission) => isVisible(permission, checkedPermissions));
}

export function allVisibleChecked(permissions, checkedPermissions) {
return visible(permissions, checkedPermissions).every(
(permission) => permission.checked && allVisibleChecked(permission.children, checkedPermissions),
);
}

// The checked permissions are snapshotted by the caller. Visibility must not shift
// as we check things, otherwise checking a permission would hide the ones after it.
export function checkVisible(permissions, checkedPermissions) {
visible(permissions, checkedPermissions).forEach((permission) => {
permission.checked = true;
checkVisible(permission.children, checkedPermissions);
});
}

export function uncheckAll(permissions) {
permissions.forEach((permission) => {
permission.checked = false;
uncheckAll(permission.children);
});
}
141 changes: 141 additions & 0 deletions resources/js/tests/components/roles/PermissionTree.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { mount } from '@vue/test-utils';
import { expect, test } from 'vitest';
import PermissionTree from '@/components/roles/PermissionTree.vue';

const permission = (value, overrides = {}) => ({
value,
label: value,
description: null,
checked: false,
hidden_by: [],
children: [],
...overrides,
});

const mountTree = (permissions, checkedPermissions = []) =>
mount(PermissionTree, {
props: { depth: 1, initialPermissions: permissions, checkedPermissions },
global: {
stubs: {
'ui-checkbox': {
props: ['value'],
template: '<div class="permission">{{ value }}</div>',
},
},
},
});

const renderedPermissions = (wrapper) => wrapper.findAll('.permission').map((el) => el.text());

test('it shows a permission when the permission hiding it is unchecked', () => {
const wrapper = mountTree([
permission('configure asset containers'),
permission('view images assets', { hidden_by: ['configure asset containers'] }),
]);

expect(renderedPermissions(wrapper)).toEqual(['configure asset containers', 'view images assets']);
});

test('it hides a permission when the permission hiding it is checked', () => {
const wrapper = mountTree(
[
permission('configure asset containers', { checked: true }),
permission('view images assets', { hidden_by: ['configure asset containers'] }),
],
['configure asset containers'],
);

expect(renderedPermissions(wrapper)).toEqual(['configure asset containers']);
});

test('it hides the children of a hidden permission', () => {
const wrapper = mountTree(
[
permission('configure asset containers', { checked: true }),
permission('view images assets', {
hidden_by: ['configure asset containers'],
children: [permission('delete images assets')],
}),
],
['configure asset containers'],
);

expect(renderedPermissions(wrapper)).toEqual(['configure asset containers']);
});

test('it only hides permissions hidden by a checked permission', () => {
const wrapper = mountTree(
[
permission('configure asset containers', { checked: true }),
permission('view images assets', { hidden_by: ['configure asset containers'] }),
permission('view blog entries', { hidden_by: ['configure collections'] }),
],
['configure asset containers'],
);

expect(renderedPermissions(wrapper)).toEqual(['configure asset containers', 'view blog entries']);
});

test('it hides a nested permission when the permission hiding it is checked', () => {
const wrapper = mountTree(
[
permission('configure collections', { checked: true }),
permission('view blog entries', {
children: [permission('publish blog entries', { hidden_by: ['configure collections'] })],
}),
],
['configure collections'],
);

expect(renderedPermissions(wrapper)).toEqual(['configure collections', 'view blog entries']);
});

test('it shows everything when no checked permissions are given', () => {
const wrapper = mount(PermissionTree, {
props: {
depth: 1,
initialPermissions: [permission('view images assets', { hidden_by: ['configure asset containers'] })],
},
global: {
stubs: { 'ui-checkbox': { props: ['value'], template: '<div class="permission">{{ value }}</div>' } },
},
});

expect(renderedPermissions(wrapper)).toEqual(['view images assets']);
});

test('unhiding a permission brings back its checked state', async () => {
const permissions = [
permission('configure asset containers', { checked: true }),
permission('view images assets', { checked: true, hidden_by: ['configure asset containers'] }),
];

const wrapper = mountTree(permissions, ['configure asset containers']);
expect(renderedPermissions(wrapper)).toEqual(['configure asset containers']);

await wrapper.setProps({ checkedPermissions: [] });

expect(renderedPermissions(wrapper)).toEqual(['configure asset containers', 'view images assets']);
expect(permissions[1].checked).toBe(true);
});

test('it hides a permission when any of the permissions hiding it are checked', () => {
const permissions = () => [
permission('configure forms'),
permission('edit forms'),
permission('edit contact form', { hidden_by: ['configure forms', 'edit forms'] }),
];

expect(renderedPermissions(mountTree(permissions(), ['configure forms']))).toEqual([
'configure forms',
'edit forms',
]);

expect(renderedPermissions(mountTree(permissions(), ['edit forms']))).toEqual(['configure forms', 'edit forms']);

expect(renderedPermissions(mountTree(permissions(), []))).toEqual([
'configure forms',
'edit forms',
'edit contact form',
]);
});
Loading
Loading