diff --git a/app/assets/javascripts/discourse/app/components/directory-item.js b/app/assets/javascripts/discourse/app/components/directory-item.js
index 709ad7a8561..0b557d6672a 100644
--- a/app/assets/javascripts/discourse/app/components/directory-item.js
+++ b/app/assets/javascripts/discourse/app/components/directory-item.js
@@ -5,4 +5,5 @@ export default Component.extend({
tagName: "tr",
classNameBindings: ["me"],
me: propertyEqual("item.user.id", "currentUser.id"),
+ columns: null,
});
diff --git a/app/assets/javascripts/discourse/app/components/directory-table.js b/app/assets/javascripts/discourse/app/components/directory-table.js
new file mode 100644
index 00000000000..f1b819310eb
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/directory-table.js
@@ -0,0 +1,17 @@
+import Component from "@ember/component";
+import { action } from "@ember/object";
+
+export default Component.extend({
+ classNames: ["directory-table-container"],
+
+ @action
+ setActiveHeader(header) {
+ // After render, scroll table left to ensure the order by column is visible
+ const scrollPixels =
+ header.offsetLeft + header.offsetWidth + 10 - this.element.offsetWidth;
+
+ if (scrollPixels > 0) {
+ this.element.scrollLeft = scrollPixels;
+ }
+ },
+});
diff --git a/app/assets/javascripts/discourse/app/components/table-header-toggle.js b/app/assets/javascripts/discourse/app/components/table-header-toggle.js
index 0ab184a1e43..afe7ef32eb3 100644
--- a/app/assets/javascripts/discourse/app/components/table-header-toggle.js
+++ b/app/assets/javascripts/discourse/app/components/table-header-toggle.js
@@ -1,6 +1,4 @@
import Component from "@ember/component";
-import I18n from "I18n";
-import discourseComputed from "discourse-common/utils/decorators";
import { iconHTML } from "discourse-common/lib/icon-library";
export default Component.extend({
@@ -10,15 +8,8 @@ export default Component.extend({
labelKey: null,
chevronIcon: null,
columnIcon: null,
-
- @discourseComputed("field", "labelKey")
- title(field, labelKey) {
- if (!labelKey) {
- labelKey = `directory.${this.field}`;
- }
-
- return I18n.t(labelKey + "_long", { defaultValue: I18n.t(labelKey) });
- },
+ translated: false,
+ onActiveRender: null,
toggleProperties() {
if (this.order === this.field) {
@@ -40,13 +31,12 @@ export default Component.extend({
},
didReceiveAttrs() {
this._super(...arguments);
+ this.set("id", `table-header-toggle-${this.field.replace(/\s/g, "")}`);
this.toggleChevron();
},
- init() {
- this._super(...arguments);
- if (this.icon) {
- let columnIcon = iconHTML(this.icon);
- this.set("columnIcon", `${columnIcon}`.htmlSafe());
+ didRender() {
+ if (this.onActiveRender && this.chevronIcon) {
+ this.onActiveRender(this.element);
}
},
});
diff --git a/app/assets/javascripts/discourse/app/controllers/edit-user-directory-columns.js b/app/assets/javascripts/discourse/app/controllers/edit-user-directory-columns.js
new file mode 100644
index 00000000000..a7fd826af8b
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/controllers/edit-user-directory-columns.js
@@ -0,0 +1,101 @@
+import Controller from "@ember/controller";
+import ModalFunctionality from "discourse/mixins/modal-functionality";
+import { ajax } from "discourse/lib/ajax";
+import EmberObject, { action } from "@ember/object";
+import { extractError } from "discourse/lib/ajax-error";
+import { reload } from "discourse/helpers/page-reloader";
+
+const UP = "up";
+const DOWN = "down";
+
+export default Controller.extend(ModalFunctionality, {
+ loading: true,
+ columns: null,
+ labelKey: null,
+
+ onShow() {
+ ajax("directory-columns.json")
+ .then((response) => {
+ this.setProperties({
+ loading: false,
+ columns: response.directory_columns
+ .sort((a, b) => (a.position > b.position ? 1 : -1))
+ .map((c) => EmberObject.create(c)),
+ });
+ })
+ .catch(extractError);
+ },
+
+ @action
+ save() {
+ this.set("loading", true);
+ const data = {
+ directory_columns: this.columns.map((c) =>
+ c.getProperties("id", "enabled", "position")
+ ),
+ };
+
+ ajax("directory-columns.json", { type: "PUT", data })
+ .then(() => {
+ reload();
+ })
+ .catch((e) => {
+ this.set("loading", false);
+ this.flash(extractError(e), "error");
+ });
+ },
+
+ @action
+ resetToDefault() {
+ let resetColumns = this.columns;
+ resetColumns
+ .sort((a, b) =>
+ (a.automatic_position || a.user_field.position + 1000) >
+ (b.automatic_position || b.user_field.position + 1000)
+ ? 1
+ : -1
+ )
+ .forEach((column, index) => {
+ column.setProperties({
+ position: column.automatic_position || index + 1,
+ enabled: column.automatic,
+ });
+ });
+ this.set("columns", resetColumns);
+ this.notifyPropertyChange("columns");
+ },
+
+ @action
+ moveUp(column) {
+ this._moveColumn(UP, column);
+ },
+
+ @action
+ moveDown(column) {
+ this._moveColumn(DOWN, column);
+ },
+
+ _moveColumn(direction, column) {
+ if (
+ (direction === UP && column.position === 1) ||
+ (direction === DOWN && column.position === this.columns.length)
+ ) {
+ return;
+ }
+
+ const positionOnClick = column.position;
+ const newPosition =
+ direction === UP ? positionOnClick - 1 : positionOnClick + 1;
+
+ const previousColumn = this.columns.find((c) => c.position === newPosition);
+
+ column.set("position", newPosition);
+ previousColumn.set("position", positionOnClick);
+
+ this.set(
+ "columns",
+ this.columns.sort((a, b) => (a.position > b.position ? 1 : -1))
+ );
+ this.notifyPropertyChange("columns");
+ },
+});
diff --git a/app/assets/javascripts/discourse/app/controllers/users.js b/app/assets/javascripts/discourse/app/controllers/users.js
index fe78c4caa2c..98f48728bc9 100644
--- a/app/assets/javascripts/discourse/app/controllers/users.js
+++ b/app/assets/javascripts/discourse/app/controllers/users.js
@@ -1,6 +1,7 @@
import Controller, { inject as controller } from "@ember/controller";
import { action } from "@ember/object";
import discourseDebounce from "discourse-common/lib/debounce";
+import showModal from "discourse/lib/show-modal";
import { equal } from "@ember/object/computed";
import { longDate } from "discourse/lib/formatter";
import { observes } from "discourse-common/utils/decorators";
@@ -9,13 +10,14 @@ export default Controller.extend({
application: controller(),
queryParams: ["period", "order", "asc", "name", "group", "exclude_usernames"],
period: "weekly",
- order: "likes_received",
+ order: "",
asc: null,
name: "",
group: null,
nameInput: null,
exclude_usernames: null,
isLoading: false,
+ columns: null,
showTimeRead: equal("period", "all"),
@@ -23,9 +25,15 @@ export default Controller.extend({
this.set("isLoading", true);
this.set("nameInput", params.name);
+ this.set("order", params.order);
+
+ const custom_field_columns = this.columns.filter((c) => !c.automatic);
+ const user_field_ids = custom_field_columns
+ .map((c) => c.user_field_id)
+ .join("|");
this.store
- .find("directoryItem", params)
+ .find("directoryItem", Object.assign(params, { user_field_ids }))
.then((model) => {
const lastUpdatedAt = model.get("resultSetMeta.last_updated_at");
this.setProperties({
@@ -39,6 +47,11 @@ export default Controller.extend({
});
},
+ @action
+ showEditColumnsModal() {
+ showModal("edit-user-directory-columns");
+ },
+
@action
onFilterChanged(filter) {
discourseDebounce(this, this._setName, filter, 500);
diff --git a/app/assets/javascripts/discourse/app/helpers/directory-item-label.js b/app/assets/javascripts/discourse/app/helpers/directory-item-label.js
new file mode 100644
index 00000000000..56723ee716e
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/helpers/directory-item-label.js
@@ -0,0 +1,10 @@
+import { htmlSafe } from "@ember/template";
+import { registerUnbound } from "discourse-common/lib/helpers";
+import I18n from "I18n";
+
+export default registerUnbound("mobile-directory-item-label", function (args) {
+ // Args should include key/values { item, column }
+
+ const count = args.item.get(args.column.name);
+ return htmlSafe(I18n.t(`directory.${args.column.name}`, { count }));
+});
diff --git a/app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js b/app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js
new file mode 100644
index 00000000000..aeab4bcbe12
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js
@@ -0,0 +1,16 @@
+import { htmlSafe } from "@ember/template";
+import { registerUnbound } from "discourse-common/lib/helpers";
+
+export default registerUnbound(
+ "directory-item-user-field-value",
+ function (args) {
+ // Args should include key/values { item, column }
+
+ const value =
+ args.item.user && args.item.user.user_fields
+ ? args.item.user.user_fields[args.column.user_field_id]
+ : null;
+ const content = value || "-";
+ return htmlSafe(`${content}`);
+ }
+);
diff --git a/app/assets/javascripts/discourse/app/helpers/directory-item-value.js b/app/assets/javascripts/discourse/app/helpers/directory-item-value.js
new file mode 100644
index 00000000000..a3c6e3d6d38
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/helpers/directory-item-value.js
@@ -0,0 +1,11 @@
+import { htmlSafe } from "@ember/template";
+import { registerUnbound } from "discourse-common/lib/helpers";
+import { number } from "discourse/lib/formatter";
+
+export default registerUnbound("directory-item-value", function (args) {
+ // Args should include key/values { item, column }
+
+ return htmlSafe(
+ `${number(args.item.get(args.column.name))}`
+ );
+});
diff --git a/app/assets/javascripts/discourse/app/helpers/directory-table-header-title.js b/app/assets/javascripts/discourse/app/helpers/directory-table-header-title.js
new file mode 100644
index 00000000000..632e3114630
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/helpers/directory-table-header-title.js
@@ -0,0 +1,19 @@
+import { registerUnbound } from "discourse-common/lib/helpers";
+import I18n from "I18n";
+import { iconHTML } from "discourse-common/lib/icon-library";
+import { htmlSafe } from "@ember/template";
+
+export default registerUnbound("directory-table-header-title", function (args) {
+ // Args should include key/values { field, labelKey, icon, translated }
+
+ let html = "";
+ if (args.icon) {
+ html += iconHTML(args.icon);
+ }
+ let labelKey = args.labelKey || `directory.${args.field}`;
+
+ html += args.translated
+ ? args.field
+ : I18n.t(labelKey + "_long", { defaultValue: I18n.t(labelKey) });
+ return htmlSafe(html);
+});
diff --git a/app/assets/javascripts/discourse/app/routes/users.js b/app/assets/javascripts/discourse/app/routes/users.js
index c055d4e2cc9..8c059adab8a 100644
--- a/app/assets/javascripts/discourse/app/routes/users.js
+++ b/app/assets/javascripts/discourse/app/routes/users.js
@@ -1,5 +1,6 @@
import DiscourseRoute from "discourse/routes/discourse";
import I18n from "I18n";
+import PreloadStore from "discourse/lib/preload-store";
export default DiscourseRoute.extend({
queryParams: {
@@ -36,11 +37,14 @@ export default DiscourseRoute.extend({
},
model(params) {
- return params;
+ const columns = PreloadStore.get("directoryColumns");
+ params.order = params.order || columns[0].name;
+ return { params, columns };
},
- setupController(controller, params) {
- controller.loadUsers(params);
+ setupController(controller, model) {
+ controller.set("columns", model.columns);
+ controller.loadUsers(model.params);
},
actions: {
diff --git a/app/assets/javascripts/discourse/app/templates/components/directory-item.hbs b/app/assets/javascripts/discourse/app/templates/components/directory-item.hbs
index d939e353b45..0d4fece4112 100644
--- a/app/assets/javascripts/discourse/app/templates/components/directory-item.hbs
+++ b/app/assets/javascripts/discourse/app/templates/components/directory-item.hbs
@@ -1,11 +1,14 @@
{{user-info user=item.user}} |
-{{number item.likes_received}} |
-{{number item.likes_given}} |
-{{number item.topic_count}} |
-{{number item.post_count}} |
-{{number item.topics_entered}} |
-{{number item.posts_read}} |
-{{number item.days_visited}} |
+{{#each columns as |column|}}
+
+ {{#if column.automatic}}
+ {{directory-item-value item=item column=column}}
+ {{else}}
+ {{directory-item-user-field-value item=item column=column}}
+ {{/if}}
+ |
+{{/each}}
+
{{#if showTimeRead}}
{{format-duration item.time_read}} |
{{/if}}
diff --git a/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs b/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs
new file mode 100644
index 00000000000..1aafa640cc0
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/templates/components/directory-table.hbs
@@ -0,0 +1,24 @@
+
+
+ {{table-header-toggle field="username" order=order asc=asc}}
+ {{#each columns as |column|}}
+ {{table-header-toggle
+ field=column.name
+ icon=column.icon
+ order=order
+ asc=asc
+ translated=column.user_field_id
+ onActiveRender=setActiveHeader
+ }}
+ {{/each}}
+
+ {{#if showTimeRead}}
+ {{i18n "directory.time_read"}} |
+ {{/if}}
+
+
+ {{#each items as |item|}}
+ {{directory-item item=item columns=columns showTimeRead=showTimeRead}}
+ {{/each}}
+
+
diff --git a/app/assets/javascripts/discourse/app/templates/components/table-header-toggle.hbs b/app/assets/javascripts/discourse/app/templates/components/table-header-toggle.hbs
index 91785f85309..408a2c79649 100644
--- a/app/assets/javascripts/discourse/app/templates/components/table-header-toggle.hbs
+++ b/app/assets/javascripts/discourse/app/templates/components/table-header-toggle.hbs
@@ -1 +1,4 @@
-
+
diff --git a/app/assets/javascripts/discourse/app/templates/mobile/components/directory-item.hbs b/app/assets/javascripts/discourse/app/templates/mobile/components/directory-item.hbs
index f55fd6e5692..e43ad11e263 100644
--- a/app/assets/javascripts/discourse/app/templates/mobile/components/directory-item.hbs
+++ b/app/assets/javascripts/discourse/app/templates/mobile/components/directory-item.hbs
@@ -1,11 +1,33 @@
{{user-info user=item.user}}
-{{user-stat value=item.likes_received label="directory.likes_received" icon="heart"}}
-{{user-stat value=item.likes_given label="directory.likes_given" icon="heart"}}
-{{user-stat value=item.topic_count label="directory.topic_count"}}
-{{user-stat value=item.post_count label="directory.post_count"}}
-{{user-stat value=item.topics_entered label="directory.topics_entered"}}
-{{user-stat value=item.posts_read label="directory.posts_read"}}
-{{user-stat value=item.days_visited label="directory.days_visited"}}
+
+{{#each columns as |column|}}
+ {{#if column.automatic}}
+
+
+ {{directory-item-value item=item column=column}}
+
+
+ {{#if column.icon}}
+ {{d-icon column.icon}}
+ {{/if}}
+ {{mobile-directory-item-label item=item column=column}}
+
+
+
+ {{else}}
+ {{#if (get item.user.user_fields column.user_field_id)}}
+
+
+ {{directory-item-user-field-value item=item column=column}}
+
+
+ {{column.name}}
+
+
+ {{/if}}
+ {{/if}}
+{{/each}}
+
{{#if showTimeRead}}
{{user-stat value=item.time_read label="directory.time_read" type="duration"}}
{{/if}}
diff --git a/app/assets/javascripts/discourse/app/templates/mobile/users.hbs b/app/assets/javascripts/discourse/app/templates/mobile/users.hbs
index 05c929f1133..44d463a001e 100644
--- a/app/assets/javascripts/discourse/app/templates/mobile/users.hbs
+++ b/app/assets/javascripts/discourse/app/templates/mobile/users.hbs
@@ -17,13 +17,20 @@
placeholderKey="directory.filter_name"
class="filter-name no-blur"
}}
+ {{#if currentUser.staff}}
+ {{d-button
+ icon="wrench"
+ action=(action "showEditColumnsModal")
+ class="btn-default open-edit-columns-btn"
+ }}
+ {{/if}}
{{#conditional-loading-spinner condition=model.loading}}
{{#if model.length}}
{{i18n "directory.total_rows" count=model.totalRows}}
{{#each model as |item|}}
- {{directory-item tagName="div" class="user" item=item showTimeRead=showTimeRead}}
+ {{directory-item tagName="div" class="user" item=item columns=columns showTimeRead=showTimeRead}}
{{/each}}
{{conditional-loading-spinner condition=model.loadingMore}}
diff --git a/app/assets/javascripts/discourse/app/templates/modal/edit-user-directory-columns.hbs b/app/assets/javascripts/discourse/app/templates/modal/edit-user-directory-columns.hbs
new file mode 100644
index 00000000000..fb3e86465eb
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/templates/modal/edit-user-directory-columns.hbs
@@ -0,0 +1,48 @@
+{{#d-modal-body title="directory.edit_columns.title"}}
+ {{#if loading}}
+ {{loading-spinner size="large"}}
+ {{else}}
+
+ {{#each columns as |column|}}
+
+
+
+
+
+ {{d-button
+ icon="arrow-up"
+ class="button-secondary move-column-up"
+ action=(action "moveUp" column)
+ }}
+ {{d-button
+ icon="arrow-down"
+ class="button-secondary"
+ action=(action "moveDown" column)
+ }}
+
+
+ {{/each}}
+
+ {{/if}}
+{{/d-modal-body}}
+
+
diff --git a/app/assets/javascripts/discourse/app/templates/users.hbs b/app/assets/javascripts/discourse/app/templates/users.hbs
index 77446fbc764..d2c85f68d8b 100644
--- a/app/assets/javascripts/discourse/app/templates/users.hbs
+++ b/app/assets/javascripts/discourse/app/templates/users.hbs
@@ -25,33 +25,19 @@
placeholderKey="directory.filter_name"
class="filter-name no-blur"
}}
+ {{#if currentUser.staff}}
+ {{d-button
+ icon="wrench"
+ action=(action "showEditColumnsModal")
+ class="btn-default open-edit-columns-btn"
+ }}
+ {{/if}}
{{#conditional-loading-spinner condition=isLoading}}
{{#if model.length}}
-
-
-
- {{table-header-toggle field="username" order=order asc=asc}}
- {{table-header-toggle field="likes_received" order=order asc=asc icon="heart"}}
- {{table-header-toggle field="likes_given" order=order asc=asc icon="heart"}}
- {{table-header-toggle field="topic_count" order=order asc=asc}}
- {{table-header-toggle field="post_count" order=order asc=asc}}
- {{table-header-toggle field="topics_entered" order=order asc=asc}}
- {{table-header-toggle field="posts_read" order=order asc=asc}}
- {{table-header-toggle field="days_visited" order=order asc=asc}}
- {{#if showTimeRead}}
- {{i18n "directory.time_read"}} |
- {{/if}}
-
-
- {{#each model as |item|}}
- {{directory-item item=item showTimeRead=showTimeRead}}
- {{/each}}
-
-
-
+ {{directory-table items=model columns=columns showTimeRead=showTimeRead order=order asc=asc}}
{{conditional-loading-spinner condition=model.loadingMore}}
{{else}}
diff --git a/app/assets/javascripts/discourse/tests/acceptance/mobile-users-test.js b/app/assets/javascripts/discourse/tests/acceptance/mobile-users-test.js
index bd888676fdd..9913b12f4e2 100644
--- a/app/assets/javascripts/discourse/tests/acceptance/mobile-users-test.js
+++ b/app/assets/javascripts/discourse/tests/acceptance/mobile-users-test.js
@@ -4,6 +4,7 @@ import { visit } from "@ember/test-helpers";
acceptance("User Directory - Mobile", function (needs) {
needs.mobileView();
+
test("Visit Page", async function (assert) {
await visit("/u");
assert.ok(exists(".directory .user"), "has a list of users");
diff --git a/app/assets/javascripts/discourse/tests/acceptance/users-test.js b/app/assets/javascripts/discourse/tests/acceptance/users-test.js
index 72899463c28..17b1e05caed 100644
--- a/app/assets/javascripts/discourse/tests/acceptance/users-test.js
+++ b/app/assets/javascripts/discourse/tests/acceptance/users-test.js
@@ -1,6 +1,11 @@
-import { acceptance, exists } from "discourse/tests/helpers/qunit-helpers";
+import {
+ acceptance,
+ exists,
+ query,
+ queryAll,
+} from "discourse/tests/helpers/qunit-helpers";
import { test } from "qunit";
-import { visit } from "@ember/test-helpers";
+import { click, visit } from "@ember/test-helpers";
acceptance("User Directory", function () {
test("Visit Page", async function (assert) {
@@ -25,4 +30,106 @@ acceptance("User Directory", function () {
assert.ok($("body.users-page").length, "has the body class");
assert.ok(exists(".directory table tr"), "has a list of users");
});
+
+ test("Custom user fields are present", async function (assert) {
+ await visit("/u");
+
+ const firstRow = query(".users-directory table tr");
+ const columnData = firstRow.querySelectorAll("td");
+ const favoriteColorTd = columnData[columnData.length - 1];
+
+ assert.equal(favoriteColorTd.querySelector("span").textContent, "Blue");
+ });
+});
+
+acceptance("User directory - Editing columns", function (needs) {
+ needs.user({ moderator: true, admin: true });
+
+ test("The automatic columns are checked and the user field columns are unchecked by default", async function (assert) {
+ await visit("/u");
+ await click(".open-edit-columns-btn");
+
+ const columns = queryAll(
+ ".edit-directory-columns-container .edit-directory-column"
+ );
+ assert.equal(columns.length, 8);
+
+ const checked = queryAll(
+ ".edit-directory-columns-container .edit-directory-column input[type='checkbox']:checked"
+ );
+ assert.equal(checked.length, 7);
+
+ const unchecked = queryAll(
+ ".edit-directory-columns-container .edit-directory-column input[type='checkbox']:not(:checked)"
+ );
+ assert.equal(unchecked.length, 1);
+ });
+
+ const fetchColumns = function () {
+ return queryAll(".edit-directory-columns-container .edit-directory-column");
+ };
+
+ test("Reordering and restoring default positions", async function (assert) {
+ await visit("/u");
+ await click(".open-edit-columns-btn");
+
+ let columns;
+ columns = fetchColumns();
+ assert.equal(
+ columns[3].querySelector(".column-name").textContent.trim(),
+ "Replies Posted"
+ );
+ assert.equal(
+ columns[4].querySelector(".column-name").textContent.trim(),
+ "Topics Viewed"
+ );
+
+ // Click on row 4 and see if they are swapped
+ await click(columns[4].querySelector(".move-column-up"));
+
+ columns = fetchColumns();
+ assert.equal(
+ columns[3].querySelector(".column-name").textContent.trim(),
+ "Topics Viewed"
+ );
+ assert.equal(
+ columns[4].querySelector(".column-name").textContent.trim(),
+ "Replies Posted"
+ );
+
+ const moveUserFieldColumnUpBtn = columns[columns.length - 1].querySelector(
+ ".move-column-up"
+ );
+ await click(moveUserFieldColumnUpBtn);
+ await click(moveUserFieldColumnUpBtn);
+ await click(moveUserFieldColumnUpBtn);
+
+ columns = fetchColumns();
+ assert.equal(
+ columns[4].querySelector(".column-name").textContent.trim(),
+ "Favorite Color"
+ );
+ assert.equal(
+ columns[5].querySelector(".column-name").textContent.trim(),
+ "Replies Posted"
+ );
+
+ // Now click restore default and check order of column names
+ await click(".reset-to-default");
+
+ let columnNames = queryAll(
+ ".edit-directory-columns-container .edit-directory-column .column-name"
+ ).toArray();
+ columnNames = columnNames.map((el) => el.textContent.trim());
+ assert.deepEqual(columnNames, [
+ "Received",
+ "Given",
+ "Topics Created",
+ "Replies Posted",
+ "Topics Viewed",
+ "Posts Read",
+ "Days Visited",
+ "Favorite Color",
+ ]);
+ });
});
diff --git a/app/assets/javascripts/discourse/tests/fixtures/directory-fixtures.js b/app/assets/javascripts/discourse/tests/fixtures/directory-fixtures.js
index dab359e8b51..b5897164910 100644
--- a/app/assets/javascripts/discourse/tests/fixtures/directory-fixtures.js
+++ b/app/assets/javascripts/discourse/tests/fixtures/directory-fixtures.js
@@ -11,7 +11,12 @@ export default {
likes_given: 7725,
topics_entered: 11453,
topic_count: 184,
- post_count: 12263
+ post_count: 12263,
+ user: {
+ user_fields: {
+ 3: "Blue"
+ }
+ }
},
{
id: 1,
diff --git a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js
index 0276932058a..4e2b1ad305a 100644
--- a/app/assets/javascripts/discourse/tests/helpers/create-pretender.js
+++ b/app/assets/javascripts/discourse/tests/helpers/create-pretender.js
@@ -929,4 +929,102 @@ export function applyDefaultHandlers(pretender) {
return [404, { "Content-Type": "application/html" }, ""];
});
+
+ pretender.get("directory-columns.json", () => {
+ return response(200, {
+ directory_columns: [
+ {
+ id: 1,
+ name: "likes_received",
+ automatic: true,
+ enabled: true,
+ automatic_position: 1,
+ position: 1,
+ icon: "heart",
+ user_field: null,
+ },
+ {
+ id: 2,
+ name: "likes_given",
+ automatic: true,
+ enabled: true,
+ automatic_position: 2,
+ position: 2,
+ icon: "heart",
+ user_field: null,
+ },
+ {
+ id: 3,
+ name: "topic_count",
+ automatic: true,
+ enabled: true,
+ automatic_position: 3,
+ position: 3,
+ icon: null,
+ user_field: null,
+ },
+ {
+ id: 4,
+ name: "post_count",
+ automatic: true,
+ enabled: true,
+ automatic_position: 4,
+ position: 4,
+ icon: null,
+ user_field: null,
+ },
+ {
+ id: 5,
+ name: "topics_entered",
+ automatic: true,
+ enabled: true,
+ automatic_position: 5,
+ position: 5,
+ icon: null,
+ user_field: null,
+ },
+ {
+ id: 6,
+ name: "posts_read",
+ automatic: true,
+ enabled: true,
+ automatic_position: 6,
+ position: 6,
+ icon: null,
+ user_field: null,
+ },
+ {
+ id: 7,
+ name: "days_visited",
+ automatic: true,
+ enabled: true,
+ automatic_position: 7,
+ position: 7,
+ icon: null,
+ user_field: null,
+ },
+ {
+ id: 9,
+ name: null,
+ automatic: false,
+ enabled: false,
+ automatic_position: null,
+ position: 8,
+ icon: null,
+ user_field: {
+ id: 3,
+ name: "Favorite Color",
+ description: "User's favorite color",
+ field_type: "text",
+ editable: false,
+ required: false,
+ show_on_profile: false,
+ show_on_user_card: true,
+ searchable: true,
+ position: 2,
+ },
+ },
+ ],
+ });
+ });
}
diff --git a/app/assets/javascripts/discourse/tests/setup-tests.js b/app/assets/javascripts/discourse/tests/setup-tests.js
index 12f93098ab1..f51ff1cfdae 100644
--- a/app/assets/javascripts/discourse/tests/setup-tests.js
+++ b/app/assets/javascripts/discourse/tests/setup-tests.js
@@ -229,6 +229,12 @@ function setupTestsCommon(application, container, config) {
});
PreloadStore.reset();
+ PreloadStore.store(
+ "directoryColumns",
+ JSON.parse(
+ '[{"name":"likes_given","automatic":true,"icon":"heart","user_field_id":null},{"name":"posts_read","automatic":true,"icon":null,"user_field_id":null},{"name":"likes_received","automatic":true,"icon":"heart","user_field_id":null},{"name":"topic_count","automatic":true,"icon":null,"user_field_id":null},{"name":"post_count","automatic":true,"icon":null,"user_field_id":null},{"name":"topics_entered","automatic":true,"icon":null,"user_field_id":null},{"name":"days_visited","automatic":true,"icon":null,"user_field_id":null},{"name":"Favorite Color","automatic":false,"icon":null,"user_field_id":3}]'
+ )
+ );
sinon.stub(ScrollingDOMMethods, "screenNotFull");
sinon.stub(ScrollingDOMMethods, "bindOnScroll");
diff --git a/app/assets/stylesheets/common/base/directory.scss b/app/assets/stylesheets/common/base/directory.scss
index 9f9564657db..3d938034f16 100644
--- a/app/assets/stylesheets/common/base/directory.scss
+++ b/app/assets/stylesheets/common/base/directory.scss
@@ -1,6 +1,16 @@
.directory {
margin-bottom: 100px;
+ .directory-table-container {
+ width: 100%;
+ overflow-x: auto;
+ }
+
+ .open-edit-columns-btn {
+ vertical-align: top;
+ padding: 0.45em 0.8em;
+ }
+
&.users-directory {
.period-chooser {
.selected-name {
@@ -61,6 +71,13 @@
.time-read {
white-space: nowrap;
}
+ .user-field-value {
+ font-size: var(--font-up-1);
+ color: var(--primary-medium);
+ @media screen and (max-width: $small-width) {
+ font-size: $font-0;
+ }
+ }
}
th.sortable {
@@ -82,3 +99,50 @@
}
}
}
+
+.edit-user-directory-columns-modal {
+ .edit-directory-columns-container {
+ .edit-directory-column {
+ display: flex;
+ justify-content: space-between;
+ padding: 12px 0;
+ border-bottom: 1px solid var(--primary-low);
+
+ .column-name {
+ display: flex;
+ align-items: center;
+ margin-bottom: 0;
+ }
+ .d-icon-heart {
+ color: var(--love);
+ margin: 0 0.25em 0 0;
+ }
+
+ .move-column-up {
+ margin-right: 5px;
+ }
+
+ .left-content,
+ .right-content {
+ display: flex;
+ align-items: center;
+ }
+
+ &:last-of-type {
+ border-bottom: none;
+ }
+ }
+ }
+ .modal-footer {
+ display: flex;
+ justify-content: space-between;
+
+ .reset-to-default {
+ margin-right: 0;
+ }
+ }
+}
+
+.edit-user-directory-columns-modal .modal-inner-container {
+ min-width: 450px;
+}
diff --git a/app/assets/stylesheets/mobile/directory.scss b/app/assets/stylesheets/mobile/directory.scss
index fc5e77237f4..25532eaa8b0 100644
--- a/app/assets/stylesheets/mobile/directory.scss
+++ b/app/assets/stylesheets/mobile/directory.scss
@@ -3,6 +3,10 @@
font-size: $font-up-1;
}
+ .open-edit-columns-btn {
+ margin: -0.7em 0 0.5em;
+ }
+
&.users-directory {
.filter-name {
width: 100%;
@@ -38,6 +42,9 @@
flex: 1 1 50%;
.value {
font-weight: bold;
+ &.user-field {
+ font-size: var(--font-down-1);
+ }
}
.label {
margin-left: 0.2em;
@@ -49,3 +56,7 @@
}
}
}
+
+.edit-user-directory-columns-modal .modal-inner-container {
+ width: 90%;
+}
diff --git a/app/controllers/admin/user_fields_controller.rb b/app/controllers/admin/user_fields_controller.rb
index 939ba7c69d4..5d6cae243cd 100644
--- a/app/controllers/admin/user_fields_controller.rb
+++ b/app/controllers/admin/user_fields_controller.rb
@@ -35,6 +35,9 @@ class Admin::UserFieldsController < Admin::AdminController
update_options(field)
if field.save
+ if !field.show_on_profile && !field.show_on_user_card
+ DirectoryColumn.where(user_field_id: field.id).destroy_all
+ end
render_serialized(field, UserFieldSerializer, root: 'user_field')
else
render_json_error(field)
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index abae103a2ec..5d16eb256cb 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -603,6 +603,7 @@ class ApplicationController < ActionController::Base
store_preloaded("customEmoji", custom_emoji)
store_preloaded("isReadOnly", @readonly_mode.to_s)
store_preloaded("activatedThemes", activated_themes_json)
+ store_preloaded("directoryColumns", directory_columns_json)
end
def preload_current_user_data
@@ -614,6 +615,20 @@ class ApplicationController < ActionController::Base
store_preloaded("topicTrackingStates", MultiJson.dump(serializer))
end
+ def directory_columns_json
+ DirectoryColumn
+ .left_joins(:user_field)
+ .where(enabled: true)
+ .order(:position)
+ .pluck('directory_columns.name',
+ 'directory_columns.automatic',
+ 'directory_columns.icon',
+ 'user_fields.id',
+ 'user_fields.name')
+ .map { |column| { name: column[0] || column[4], automatic: column[1], icon: column[2], user_field_id: column[3] } }
+ .to_json
+ end
+
def custom_html_json
target = view_context.mobile_view? ? :mobile : :desktop
diff --git a/app/controllers/directory_columns_controller.rb b/app/controllers/directory_columns_controller.rb
new file mode 100644
index 00000000000..2efdcd6dd4f
--- /dev/null
+++ b/app/controllers/directory_columns_controller.rb
@@ -0,0 +1,62 @@
+# frozen_string_literal: true
+
+class DirectoryColumnsController < ApplicationController
+ requires_login
+
+ def index
+ raise Discourse::NotFound unless guardian.is_staff?
+
+ ensure_user_fields_have_columns
+
+ columns = DirectoryColumn.includes(:user_field).all
+ render_json_dump(directory_columns: serialize_data(columns, DirectoryColumnSerializer))
+ end
+
+ def update
+ raise Discourse::NotFound unless guardian.is_staff?
+ params.require(:directory_columns)
+ directory_column_params = params.permit(directory_columns: {})
+ directory_columns = DirectoryColumn.all
+
+ has_enabled_column = directory_column_params[:directory_columns].values.any? do |column_data|
+ column_data[:enabled].to_s == "true"
+ end
+ raise Discourse::InvalidParameters, "Must have at least one column enabled" unless has_enabled_column
+
+ directory_column_params[:directory_columns].values.each do |column_data|
+ existing_column = directory_columns.detect { |c| c.id == column_data[:id].to_i }
+ if (existing_column.enabled != column_data[:enabled] || existing_column.position != column_data[:position].to_i)
+ existing_column.update(enabled: column_data[:enabled], position: column_data[:position])
+ end
+ end
+
+ render json: success_json
+ end
+
+ private
+
+ def ensure_user_fields_have_columns
+ user_fields_without_column =
+ UserField.left_outer_joins(:directory_column)
+ .where(directory_column: { user_field_id: nil })
+ .where("show_on_profile=? OR show_on_user_card=?", true, true)
+
+ return unless user_fields_without_column.count > 0
+
+ next_position = DirectoryColumn.maximum("position") + 1
+
+ new_directory_column_attrs = []
+ user_fields_without_column.each do |user_field|
+ new_directory_column_attrs.push({
+ user_field_id: user_field.id,
+ enabled: false,
+ automatic: false,
+ position: next_position
+ })
+
+ next_position += 1
+ end
+
+ DirectoryColumn.insert_all(new_directory_column_attrs)
+ end
+end
diff --git a/app/controllers/directory_items_controller.rb b/app/controllers/directory_items_controller.rb
index fd6286fab10..8f7314f75e8 100644
--- a/app/controllers/directory_items_controller.rb
+++ b/app/controllers/directory_items_controller.rb
@@ -32,6 +32,14 @@ class DirectoryItemsController < ApplicationController
result = result.order("directory_items.#{order} #{dir}, directory_items.id")
elsif params[:order] === 'username'
result = result.order("users.#{order} #{dir}, directory_items.id")
+ else
+ user_field = UserField.find_by(name: params[:order])
+ if user_field
+ result = result
+ .joins(:user)
+ .joins("LEFT OUTER JOIN user_custom_fields ON user_custom_fields.user_id = users.id AND user_custom_fields.name = 'user_field_#{user_field.id}'")
+ .order("user_custom_fields.name = 'user_field_#{user_field.id}' ASC, user_custom_fields.value #{dir}")
+ end
end
if period_type == DirectoryItem.period_types[:all]
@@ -84,7 +92,14 @@ class DirectoryItemsController < ApplicationController
end
last_updated_at = DirectoryItem.last_updated_at(period_type)
- render_json_dump(directory_items: serialize_data(result, DirectoryItemSerializer),
+
+ serializer_opts = {}
+ if params[:user_field_ids]
+ serializer_opts[:user_field_ids] = params[:user_field_ids]&.split("|")&.map(&:to_i)
+ end
+
+ serialized = serialize_data(result, DirectoryItemSerializer, serializer_opts)
+ render_json_dump(directory_items: serialized,
meta: {
last_updated_at: last_updated_at,
total_rows_directory_items: result_count,
diff --git a/app/models/directory_column.rb b/app/models/directory_column.rb
new file mode 100644
index 00000000000..4a3bc3546e0
--- /dev/null
+++ b/app/models/directory_column.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class DirectoryColumn < ActiveRecord::Base
+ belongs_to :user_field
+end
diff --git a/app/models/user_field.rb b/app/models/user_field.rb
index 9adc5b3131d..b024574894c 100644
--- a/app/models/user_field.rb
+++ b/app/models/user_field.rb
@@ -7,6 +7,7 @@ class UserField < ActiveRecord::Base
validates_presence_of :description, :field_type
validates_presence_of :name, unless: -> { field_type == "confirm" }
has_many :user_field_options, dependent: :destroy
+ has_one :directory_column, dependent: :destroy
accepts_nested_attributes_for :user_field_options
after_save :queue_index_search
diff --git a/app/serializers/directory_column_serializer.rb b/app/serializers/directory_column_serializer.rb
new file mode 100644
index 00000000000..18e18ba67b8
--- /dev/null
+++ b/app/serializers/directory_column_serializer.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+class DirectoryColumnSerializer < ApplicationSerializer
+ attributes :id,
+ :name,
+ :automatic,
+ :enabled,
+ :automatic_position,
+ :position,
+ :icon
+
+ has_one :user_field, serializer: UserFieldSerializer, embed: :objects
+end
diff --git a/app/serializers/directory_item_serializer.rb b/app/serializers/directory_item_serializer.rb
index 80187cabb6d..02a15ae3f47 100644
--- a/app/serializers/directory_item_serializer.rb
+++ b/app/serializers/directory_item_serializer.rb
@@ -4,6 +4,16 @@ class DirectoryItemSerializer < ApplicationSerializer
class UserSerializer < UserNameSerializer
include UserPrimaryGroupMixin
+
+ attributes :user_fields
+
+ def user_fields
+ object.user_fields(@options[:user_field_ids])
+ end
+
+ def include_user_fields?
+ user_fields.present?
+ end
end
attributes :id,
@@ -23,5 +33,4 @@ class DirectoryItemSerializer < ApplicationSerializer
def include_time_read?
object.period_type == DirectoryItem.period_types[:all]
end
-
end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 518ded4e2cc..96285216554 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -646,6 +646,10 @@ en:
total_rows:
one: "%{count} user"
other: "%{count} users"
+ edit_columns:
+ title: "Edit Directory Columns"
+ save: "Save"
+ reset_to_default: "Reset to default"
group_histories:
actions:
diff --git a/config/routes.rb b/config/routes.rb
index f1f1f99ed62..e395cd0df1f 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -387,6 +387,8 @@ Discourse::Application.routes.draw do
get ".well-known/change-password", to: redirect(relative_url_root + 'my/preferences/account', status: 302)
get "user-cards" => "users#cards", format: :json
+ get "directory-columns" => "directory_columns#index", format: :json
+ put "directory-columns" => "directory_columns#update", format: :json
%w{users u}.each_with_index do |root_path, index|
get "#{root_path}" => "users#index", constraints: { format: 'html' }
diff --git a/db/migrate/20210527131318_create_directory_columns.rb b/db/migrate/20210527131318_create_directory_columns.rb
new file mode 100644
index 00000000000..81fa5421d10
--- /dev/null
+++ b/db/migrate/20210527131318_create_directory_columns.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+class CreateDirectoryColumns < ActiveRecord::Migration[6.1]
+ def up
+ create_table :directory_columns do |t|
+ t.string :name, null: true
+ t.integer :automatic_position, null: true
+ t.string :icon, null: true
+ t.integer :user_field_id, null: true
+ t.boolean :automatic, null: false
+ t.boolean :enabled, null: false
+ t.integer :position, null: false
+ t.datetime :created_at, default: -> { 'CURRENT_TIMESTAMP' }
+ end
+
+ add_index :directory_columns, [:enabled, :position, :user_field_id], name: "directory_column_index"
+
+ create_automatic_columns
+ end
+
+ def down
+ drop_table :directory_columns
+ end
+
+ def create_automatic_columns
+ DB.exec(
+ <<~SQL
+ INSERT INTO directory_columns (
+ name, automatic, enabled, automatic_position, position, icon
+ )
+ VALUES
+ ( 'likes_received', true, true, 1, 1, 'heart' ),
+ ( 'likes_given', true, true, 2, 2, 'heart' ),
+ ( 'topic_count', true, true, 3, 3, NULL ),
+ ( 'post_count', true, true, 4, 4, NULL ),
+ ( 'topics_entered', true, true, 5, 5, NULL ),
+ ( 'posts_read', true, true, 6, 6, NULL ),
+ ( 'days_visited', true, true, 7, 7, NULL );
+ SQL
+ )
+ end
+end
diff --git a/spec/requests/admin/user_fields_controller_spec.rb b/spec/requests/admin/user_fields_controller_spec.rb
index 672e1f6ede0..828795859c6 100644
--- a/spec/requests/admin/user_fields_controller_spec.rb
+++ b/spec/requests/admin/user_fields_controller_spec.rb
@@ -124,6 +124,21 @@ describe Admin::UserFieldsController do
user_field.reload
expect(user_field.user_field_options.size).to eq(2)
end
+
+ it "removes directory column record if not public" do
+ next_position = DirectoryColumn.maximum("position") + 1
+ DirectoryColumn.create(
+ user_field_id: user_field.id,
+ enabled: false,
+ automatic: false,
+ position: next_position
+ )
+ expect {
+ put "/admin/customize/user_fields/#{user_field.id}.json", params: {
+ user_field: { show_on_profile: false, show_on_user_card: false, searchable: true }
+ }
+ }.to change { DirectoryColumn.count }.by(-1)
+ end
end
end
end
diff --git a/spec/requests/directory_columns_controller_spec.rb b/spec/requests/directory_columns_controller_spec.rb
new file mode 100644
index 00000000000..6f01eb01ad6
--- /dev/null
+++ b/spec/requests/directory_columns_controller_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe DirectoryColumnsController do
+ fab!(:user) { Fabricate(:user) }
+ fab!(:admin) { Fabricate(:admin) }
+
+ describe "#index" do
+ fab!(:public_user_field) { Fabricate(:user_field, show_on_profile: true) }
+ fab!(:private_user_field) { Fabricate(:user_field, show_on_profile: false, show_on_user_card: false) }
+
+ it "creates directory column records for public user fields" do
+ sign_in(admin)
+
+ expect {
+ get "/directory-columns.json"
+ }.to change { DirectoryColumn.count }.by(1)
+ end
+
+ it "returns a 403 when not logged in as staff member" do
+ sign_in(user)
+ get "/directory-columns.json"
+
+ expect(response.status).to eq(404)
+ end
+ end
+
+ describe "#update" do
+ let(:first_directory_column_id) { DirectoryColumn.first.id }
+ let(:second_directory_column_id) { DirectoryColumn.second.id }
+ let(:params) {
+ {
+ directory_columns: {
+ "0": {
+ id: first_directory_column_id,
+ enabled: false,
+ position: 1
+ },
+ "1": {
+ id: second_directory_column_id,
+ enabled: true,
+ position: 1
+ }
+ }
+ }
+ }
+
+ it "updates exising directory columns" do
+ sign_in(admin)
+
+ expect {
+ put "/directory-columns.json", params: params
+ }.to change { DirectoryColumn.find(first_directory_column_id).enabled }.from(true).to(false)
+ end
+
+ it "does not let all columns be disabled" do
+ sign_in(admin)
+ bad_params = params
+ bad_params[:directory_columns][:"1"][:enabled] = false
+
+ put "/directory-columns.json", params: bad_params
+
+ expect(response.status).to eq(400)
+ end
+
+ it "returns a 404 when not logged in as a staff member" do
+ sign_in(user)
+ put "/directory-columns.json", params: params
+
+ expect(response.status).to eq(404)
+ end
+ end
+end