UX: Revamp category security tab (#11273)

This commit is contained in:
Penar Musaraj 2020-11-20 10:44:34 -05:00 committed by GitHub
parent dbcf722ab9
commit 7539c2ed7f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 454 additions and 199 deletions

View File

@ -0,0 +1,123 @@
import I18n from "I18n";
import Component from "@ember/component";
import discourseComputed, { observes } from "discourse-common/utils/decorators";
import PermissionType from "discourse/models/permission-type";
import { equal, alias } from "@ember/object/computed";
const EVERYONE = "everyone";
export default Component.extend({
classNames: ["permission-row", "row-body"],
canCreate: equal("type", PermissionType.FULL),
everyonePermissionType: alias("everyonePermission.permission_type"),
@discourseComputed("type")
canReply(value) {
return (
value === PermissionType.CREATE_POST || value === PermissionType.FULL
);
},
@discourseComputed("type")
canReplyIcon() {
return this.canReply ? "check-square" : "far-square";
},
@discourseComputed("type")
canCreateIcon() {
return this.canCreate ? "check-square" : "far-square";
},
@discourseComputed("type")
replyGranted() {
return this.type <= PermissionType.CREATE_POST ? "reply-granted" : "";
},
@discourseComputed("type")
createGranted() {
return this.type === PermissionType.FULL ? "create-granted" : "";
},
@observes("everyonePermissionType")
inheritFromEveryone() {
if (this.group_name === EVERYONE) {
return;
}
// groups cannot have a lesser permission than "everyone"
if (this.everyonePermissionType < this.type) {
this.updatePermission(this.everyonePermissionType);
}
},
@discourseComputed("everyonePermissionType", "type")
replyDisabled(everyonePermissionType) {
if (
this.group_name !== EVERYONE &&
everyonePermissionType &&
everyonePermissionType <= PermissionType.CREATE_POST
) {
return true;
}
return false;
},
@discourseComputed("replyDisabled")
replyTooltip() {
return this.replyDisabled
? I18n.t("category.permissions.inherited")
: I18n.t("category.permissions.toggle_reply");
},
@discourseComputed("everyonePermissionType", "type")
createDisabled(everyonePermissionType) {
if (
this.group_name !== EVERYONE &&
everyonePermissionType &&
everyonePermissionType === PermissionType.FULL
) {
return true;
}
return false;
},
@discourseComputed("createDisabled")
createTooltip() {
return this.createDisabled
? I18n.t("category.permissions.inherited")
: I18n.t("category.permissions.toggle_full");
},
updatePermission(type) {
this.category.updatePermission(this.group_name, type);
},
actions: {
removeRow() {
this.category.removePermission(this.group_name);
},
setPermissionReply() {
if (this.type <= PermissionType.CREATE_POST) {
this.updatePermission(PermissionType.READONLY);
} else {
this.updatePermission(PermissionType.CREATE_POST);
}
},
setPermissionFull() {
if (
this.group_name !== EVERYONE &&
this.everyonePermissionType === PermissionType.FULL
) {
return;
}
if (this.type === PermissionType.FULL) {
this.updatePermission(PermissionType.CREATE_POST);
} else {
this.updatePermission(PermissionType.FULL);
}
},
},
});

View File

@ -1,78 +1,39 @@
import { buildCategoryPanel } from "discourse/components/edit-category-panel";
import PermissionType from "discourse/models/permission-type";
import { on } from "discourse-common/utils/decorators";
import discourseComputed from "discourse-common/utils/decorators";
import { not } from "@ember/object/computed";
export default buildCategoryPanel("security", {
editingPermissions: false,
selectedGroup: null,
selectedPermission: null,
showPendingGroupChangesAlert: false,
interactedWithDropdowns: false,
noGroupSelected: not("selectedGroup"),
@on("init")
_setup() {
this.setProperties({
selectedGroup: this.get("category.availableGroups.firstObject"),
selectedPermission: this.get(
"category.availablePermissions.firstObject.id"
),
});
@discourseComputed("category.permissions.@each.permission_type")
everyonePermission(permissions) {
return permissions.findBy("group_name", "everyone");
},
@on("init")
_registerValidator() {
this.registerValidator(() => {
if (
!this.showPendingGroupChangesAlert &&
this.interactedWithDropdowns &&
this.activeTab
) {
this.set("showPendingGroupChangesAlert", true);
return true;
}
});
@discourseComputed("category.permissions.@each.permission_type")
everyoneGrantedFull() {
return (
this.everyonePermission &&
this.everyonePermission.permission_type === PermissionType.FULL
);
},
@discourseComputed("everyonePermission")
minimumPermission(everyonePermission) {
return everyonePermission
? everyonePermission.permission_type
: PermissionType.READONLY;
},
actions: {
onSelectGroup(selectedGroup) {
this.setProperties({
interactedWithDropdowns: true,
selectedGroup,
});
},
onSelectPermission(selectedPermission) {
this.setProperties({
interactedWithDropdowns: true,
selectedPermission,
});
},
editPermissions() {
if (!this.get("category.is_special")) {
this.set("editingPermissions", true);
}
},
addPermission(group, id) {
if (!this.get("category.is_special")) {
onSelectGroup(group_name) {
this.category.addPermission({
group_name: group + "",
permission: PermissionType.create({ id: parseInt(id, 10) }),
group_name,
permission_type: this.minimumPermission,
});
}
this.setProperties({
selectedGroup: this.get("category.availableGroups.firstObject"),
showPendingGroupChangesAlert: false,
interactedWithDropdowns: false,
});
},
removePermission(permission) {
if (!this.get("category.is_special")) {
this.category.removePermission(permission);
}
},
},
});

View File

@ -6,6 +6,7 @@ import { propertyEqual } from "discourse/lib/computed";
import getURL from "discourse-common/lib/get-url";
import { empty } from "@ember/object/computed";
import DiscourseURL from "discourse/lib/url";
import { underscore } from "@ember/string";
export default Component.extend({
tagName: "li",
@ -21,7 +22,7 @@ export default Component.extend({
@discourseComputed("tab")
title(tab) {
return I18n.t("category." + tab.replace("-", "_"));
return I18n.t(`category.${underscore(tab)}`);
},
didInsertElement() {

View File

@ -7,6 +7,7 @@ import DiscourseURL from "discourse/lib/url";
import { readOnly } from "@ember/object/computed";
import PermissionType from "discourse/models/permission-type";
import { NotificationLevels } from "discourse/lib/notification-levels";
import { underscore } from "@ember/string";
export default Controller.extend({
selectedTab: "general",
@ -69,6 +70,11 @@ export default Controller.extend({
: I18n.t("category.create");
},
@discourseComputed("selectedTab")
selectedTabTitle(tab) {
return I18n.t(`category.${underscore(tab)}`);
},
actions: {
registerValidator(validator) {
this.validators.push(validator);

View File

@ -10,6 +10,8 @@ import Site from "discourse/models/site";
import User from "discourse/models/user";
import { getOwner } from "discourse-common/lib/get-owner";
const STAFF_GROUP_NAME = "staff";
const Category = RestModel.extend({
permissions: null,
@ -22,15 +24,13 @@ const Category = RestModel.extend({
this.set("availableGroups", availableGroups);
const groupPermissions = this.group_permissions;
if (groupPermissions) {
this.set(
"permissions",
groupPermissions.map((elem) => {
availableGroups.removeObject(elem.group_name);
return {
group_name: elem.group_name,
permission: PermissionType.create({ id: elem.permission_type }),
};
return elem;
})
);
}
@ -231,7 +231,12 @@ const Category = RestModel.extend({
_permissionsForUpdate() {
const permissions = this.permissions;
let rval = {};
permissions.forEach((p) => (rval[p.group_name] = p.permission.id));
if (permissions.length) {
permissions.forEach((p) => (rval[p.group_name] = p.permission_type));
} else {
// empty permissions => staff-only access
rval[STAFF_GROUP_NAME] = PermissionType.FULL;
}
return rval;
},
@ -246,9 +251,20 @@ const Category = RestModel.extend({
this.availableGroups.removeObject(permission.group_name);
},
removePermission(permission) {
removePermission(group_name) {
const permission = this.permissions.findBy("group_name", group_name);
if (permission) {
this.permissions.removeObject(permission);
this.availableGroups.addObject(permission.group_name);
this.availableGroups.addObject(group_name);
}
},
updatePermission(group_name, type) {
this.permissions.forEach((p, i) => {
if (p.group_name === group_name) {
this.set(`permissions.${i}.permission_type`, type);
}
});
},
@discourseComputed("topics")

View File

@ -0,0 +1,29 @@
<span class="group-name">
<span class="group-name-label">{{group_name}}</span>
<a class="remove-permission" href {{action "removeRow"}}>
{{d-icon "far-trash-alt"}}
</a>
</span>
<span class="options actionable">
{{d-button
icon="check-square"
class="btn btn-flat see"
disabled=true
}}
{{d-button
icon=canReplyIcon
action=(action "setPermissionReply")
translatedTitle=replyTooltip
class=(concat "btn btn-flat reply-toggle " replyGranted)
disabled=replyDisabled
}}
{{d-button
icon=canCreateIcon
action=(action "setPermissionFull")
translatedTitle=createTooltip
class=(concat "btn btn-flat create-toggle " createGranted)
disabled=createDisabled
}}
</span>

View File

@ -12,14 +12,16 @@
<section class="field">
<label>{{i18n "category.parent"}}</label>
{{category-chooser
rootNone=true
value=category.parent_category_id
excludeCategoryId=category.id
categories=parentCategories
allowSubCategories=true
allowUncategorized=false
allowRestrictedCategories=true
onChange=(action (mut category.parent_category_id))
options=(hash
excludeCategoryId=category.id
none=true
)
}}
</section>
{{/if}}

View File

@ -6,62 +6,50 @@
<p class="warning">{{i18n "category.special_warning"}}</p>
{{/if}}
{{/if}}
{{#unless category.isUncategorizedCategory}}
<ul class="permission-list">
{{#unless category.is_special}}
<div class="category-permissions-table">
<div class="permission-row row-header">
<span class="group-name">{{i18n "category.permissions.group"}}</span>
<span class="options">
<span class="cell">{{i18n "category.permissions.see"}}</span>
<span class="cell">{{i18n "category.permissions.reply"}}</span>
<span class="cell">{{i18n "category.permissions.create"}}</span>
</span>
</div>
{{#each category.permissions as |p|}}
<li>
<span class="name"><span class="badge-group">{{p.group_name}}</span></span>
{{html-safe (i18n "category.can")}}
<span class="permission">{{p.permission.description}}</span>
{{#if editingPermissions}}
<a class="remove-permission" href {{action "removePermission" p}}>{{d-icon "times-circle"}}</a>
{{/if}}
</li>
{{category-permission-row group_name=p.group_name type=p.permission_type category=category everyonePermission=everyonePermission}}
{{/each}}
</ul>
{{#unless category.permissions}}
<div class="permission-row row-empty">
{{i18n "category.permissions.no_groups_selected"}}
</div>
{{/unless}}
{{#if editingPermissions}}
{{#if category.availableGroups}}
<div class="add-group">
<span class="group-name">
{{combo-box
class="available-groups"
content=category.availableGroups
onChange=(action "onSelectGroup")
value=selectedGroup
value=null
valueProperty=null
nameProperty=null
options=(hash
placementStrategy="absolute"
none="category.security_add_group"
)
}}
{{combo-box
class="permission-selector"
nameProperty="description"
content=category.availablePermissions
onChange=(action "onSelectPermission")
value=selectedPermission
options=(hash
placementStrategy="absolute"
)
}}
{{d-button
action=(action "addPermission" selectedGroup selectedPermission)
class="btn-primary add-permission"
icon="plus"}}
{{#if showPendingGroupChangesAlert}}
<div class="pending-permission-change-alert">
<div class="arrow-div"></div>
{{i18n "category.pending_permission_change_alert" group=selectedGroup}}
</span>
</div>
{{/if}}
</div>
{{#if everyoneGrantedFull}}
<p class="warning">{{i18n "category.permissions.everyone_has_access"}}</p>
{{/if}}
{{else}}
{{#unless category.is_special}}
{{d-button
action=(action "editPermissions")
class="btn-default edit-permission"
label="category.edit_permissions"}}
{{/unless}}
{{/if}}
</section>
{{plugin-outlet name="category-custom-security" args=(hash category=category) connectorTagName="" tagName="section"}}

View File

@ -1,6 +1,4 @@
<section>
<h3>{{i18n "category.settings_sections.general"}}</h3>
{{#if showPositionInput}}
<section class="field position-fields">
<label for="category-position">

View File

@ -1,2 +1 @@
<label>{{i18n "category.topic_template"}}</label>
{{d-editor value=category.topic_template showLink=showInsertLinkButton}}

View File

@ -26,9 +26,13 @@
</ul>
</div>
<div class="edit-category-content">
<h3>{{selectedTabTitle}}</h3>
{{#each panels as |tab|}}
{{component tab selectedTab=selectedTab category=model registerValidator=(action "registerValidator")}}
{{/each}}
</div>
<div class="edit-category-footer">
{{d-button id="save-category" class="btn-primary" disabled=disabled action=(action "saveCategory") label=saveLabel}}

View File

@ -3,6 +3,7 @@ import { click, visit } from "@ember/test-helpers";
import { test } from "qunit";
import selectKit from "discourse/tests/helpers/select-kit-helper";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import I18n from "I18n";
acceptance("Category Edit - security", function (needs) {
needs.user();
@ -10,13 +11,12 @@ acceptance("Category Edit - security", function (needs) {
test("default", async function (assert) {
await visit("/c/bug/edit/security");
const $firstItem = queryAll(".permission-list li:eq(0)");
const badgeName = $firstItem.find(".badge-group").text();
const firstRow = queryAll(".row-body").first();
const badgeName = firstRow.find(".group-name-label").text();
assert.equal(badgeName, "everyone");
const permission = $firstItem.find(".permission").text();
assert.equal(permission, "Create / Reply / See");
const permission = firstRow.find(".d-icon-check-square");
assert.equal(permission.length, 3);
});
test("removing a permission", async function (assert) {
@ -24,47 +24,42 @@ acceptance("Category Edit - security", function (needs) {
await visit("/c/bug/edit/security");
await click(".edit-category-tab-security .edit-permission");
await availableGroups.expand();
assert.notOk(
availableGroups.rowByValue("everyone").exists(),
"everyone is already used and is not in the available groups"
);
await click(
".edit-category-tab-security .permission-list li:first-of-type .remove-permission"
);
await click(".row-body .remove-permission");
await availableGroups.expand();
assert.ok(
availableGroups.rowByValue("everyone").exists(),
"everyone has been removed and appears in the available groups"
);
assert.ok(
queryAll(".row-empty").text(),
I18n.t("category.permissions.no_groups_selected"),
"shows message when no groups are selected"
);
});
test("adding a permission", async function (assert) {
const availableGroups = selectKit(".available-groups");
const permissionSelector = selectKit(".permission-selector");
await visit("/c/bug/edit/security");
await click(".edit-category-tab-security .edit-permission");
await availableGroups.expand();
await availableGroups.selectRowByValue("staff");
await permissionSelector.expand();
await permissionSelector.selectRowByValue("2");
await click(".edit-category-tab-security .add-permission");
const $addedPermissionItem = queryAll(
".edit-category-tab-security .permission-list li:nth-child(2)"
const addedRow = queryAll(".row-body").last();
assert.equal(addedRow.find(".group-name-label").text(), "staff");
assert.equal(
addedRow.find(".d-icon-check-square").length,
3,
"new row permissions match default 'everyone' permissions"
);
const badgeName = $addedPermissionItem.find(".badge-group").text();
assert.equal(badgeName, "staff");
const permission = $addedPermissionItem.find(".permission").text();
assert.equal(permission, "Reply / See");
});
test("adding a previously removed permission", async function (assert) {
@ -72,33 +67,103 @@ acceptance("Category Edit - security", function (needs) {
await visit("/c/bug/edit/security");
await click(".edit-category-tab-security .edit-permission");
await click(
".edit-category-tab-security .permission-list li:first-of-type .remove-permission"
);
await click(".row-body .remove-permission");
assert.equal(
queryAll(".edit-category-tab-security .permission-list li").length,
queryAll(".row-body").length,
0,
"it removes the permission from the list"
"removes the permission from the list"
);
await availableGroups.expand();
await availableGroups.selectRowByValue("everyone");
await click(".edit-category-tab-security .add-permission");
assert.equal(
queryAll(".edit-category-tab-security .permission-list li").length,
queryAll(".row-body").length,
1,
"it adds the permission to the list"
"adds back the permission tp the list"
);
const $firstItem = queryAll(".permission-list li:eq(0)");
const firstRow = queryAll(".row-body").first();
const badgeName = $firstItem.find(".badge-group").text();
assert.equal(badgeName, "everyone");
assert.equal(firstRow.find(".group-name-label").text(), "everyone");
assert.equal(
firstRow.find(".d-icon-check-square").length,
1,
"adds only 'See' permission for a new row"
);
});
const permission = $firstItem.find(".permission").text();
assert.equal(permission, "Create / Reply / See");
test("editing permissions", async function (assert) {
const availableGroups = selectKit(".available-groups");
await visit("/c/bug/edit/security");
const everyoneRow = queryAll(".row-body").first();
assert.equal(
everyoneRow.find(".reply-granted, .create-granted").length,
2,
"everyone has full permissions by default"
);
await availableGroups.expand();
await availableGroups.selectRowByValue("staff");
const staffRow = queryAll(".row-body").last();
assert.equal(
staffRow.find(".reply-granted, .create-granted").length,
2,
"staff group also has full permissions"
);
await click(everyoneRow.find(".reply-toggle"));
assert.equal(
everyoneRow.find(".reply-granted, .create-granted").length,
0,
"everyone does not have reply or create"
);
assert.equal(
staffRow.find(".reply-granted, .create-granted").length,
2,
"staff group still has full permissions"
);
await click(staffRow.find(".reply-toggle"));
assert.equal(
everyoneRow.find(".reply-granted, .create-granted").length,
0,
"everyone permission unchanged"
);
assert.equal(
staffRow.find(".reply-granted").length,
0,
"staff does not have reply permission"
);
assert.equal(
staffRow.find(".create-granted").length,
0,
"staff does not have create permission"
);
await click(everyoneRow.find(".create-toggle"));
assert.equal(
everyoneRow.find(".reply-granted, .create-granted").length,
2,
"everyone has full permissions"
);
assert.equal(
staffRow.find(".reply-granted, .create-granted").length,
2,
"staff group has full permissions (inherited from everyone)"
);
});
});

View File

@ -27,6 +27,10 @@ div.edit-category {
}
}
.edit-category-content {
grid-area: content;
}
#list-area & h2 {
margin: 0;
}
@ -35,16 +39,16 @@ div.edit-category {
margin-bottom: 1em;
}
.edit-category-tab-general {
.category-chooser {
width: unquote("min(340px, 90%)");
}
.warning {
background-color: var(--tertiary-low);
padding: 0.5em 2.5em 0.5em 1em;
margin-top: 0;
}
.edit-category-tab-general {
.category-chooser {
width: unquote("min(340px, 90%)");
}
}
.edit-category-tab-security {
@ -71,11 +75,6 @@ div.edit-category {
}
}
.add-permission {
position: relative;
top: 0.1em;
}
.permission-list {
list-style: none;
margin: 0 0 30px;
@ -147,7 +146,7 @@ div.edit-category {
display: flex;
justify-content: space-between;
align-self: start;
padding-bottom: 2em;
padding: 0 1.5em 2em 0;
.disable-info {
position: relative;
@ -175,3 +174,70 @@ div.edit-category {
}
}
}
.category-permissions-table {
max-width: 450px;
margin-bottom: 2em;
.permission-row {
border-bottom: 1px solid var(--primary-low);
display: flex;
&.row-header {
font-weight: bold;
border-bottom: 2px solid var(--primary-low);
}
.group-name,
.options {
display: flex;
box-sizing: border-box;
text-align: center;
width: 50%;
margin: 0px;
align-items: center;
}
.group-name {
text-align: left;
padding: 0.5em;
padding-left: 0;
.group-name-label {
@include ellipsis;
}
}
.cell,
.btn-flat {
width: 33%;
padding: 0.5em;
}
.btn-flat:hover {
background-color: transparent;
}
.btn-flat .d-icon-check-square,
.btn-flat:hover .d-icon-check-square {
color: var(--success);
}
}
.remove-permission {
margin-left: 0.5em;
padding: 0.15em;
color: var(--danger);
&:hover {
color: var(--danger-hover);
}
}
.row-empty {
padding: 0.5em 0;
}
.row-empty {
color: var(--primary-medium);
}
.add-group {
margin: 1em 0;
.group-name {
width: 100%;
}
}
}

View File

@ -39,24 +39,6 @@ div.edit-category {
}
}
.edit-category-tab,
.edit-category-footer {
background-color: var(--secondary);
transition: transform 0.2s ease;
transform: translateX(0);
}
&.expanded-menu {
.edit-category-tab,
.edit-category-footer {
transform: translateX(45%);
}
.nav-stacked {
left: 0px;
}
}
.edit-category-title {
justify-content: start;
align-items: center;
@ -74,7 +56,11 @@ div.edit-category {
}
}
.edit-category-content {
padding: 0.5em;
}
.edit-category-footer {
padding-bottom: 2em;
padding: 0 0.5em 2em 0.5em;
}
}

View File

@ -2961,6 +2961,17 @@ en:
change_in_category_topic: "Edit Description"
already_used: "This color has been used by another category"
security: "Security"
security_add_group: "Add a group"
permissions:
group: "Group"
see: "See"
reply: "Reply"
create: "Create"
no_groups_selected: "No groups have been granted access; this category will only be visible to staff."
everyone_has_access: "This category is public, everyone can see, reply and create posts. To restrict permissions, remove one or more of the permissions granted to the \"everyone\" group."
toggle_reply: "Toggle Reply permission"
toggle_full: "Toggle Create permission"
inherited: "This permission is inherited from \"everyone\""
special_warning: "Warning: This category is a pre-seeded category and the security settings cannot be edited. If you do not wish to use this category, delete it instead of repurposing it."
uncategorized_security_warning: "This category is special. It is intended as holding area for topics that have no category; it cannot have security settings."
uncategorized_general_warning: 'This category is special. It is used as the default category for new topics that do not have a category selected. If you want to prevent this behavior and force category selection, <a href="%{settingLink}">please disable the setting here</a>. If you want to change the name or description, go to <a href="%{customizeLink}">Customize / Text Content</a>.'