From 0cba4d73c1d5b0497857ce23b33962c169d27ae9 Mon Sep 17 00:00:00 2001 From: Mark VanLandingham Date: Mon, 7 Jun 2021 12:34:01 -0500 Subject: [PATCH] FEATURE: Add user custom fields to user directory (#13238) --- .../app/components/directory-item.js | 1 + .../app/components/directory-table.js | 17 +++ .../app/components/table-header-toggle.js | 22 +--- .../edit-user-directory-columns.js | 101 ++++++++++++++++ .../discourse/app/controllers/users.js | 17 ++- .../app/helpers/directory-item-label.js | 10 ++ .../directory-item-user-field-value.js | 16 +++ .../app/helpers/directory-item-value.js | 11 ++ .../helpers/directory-table-header-title.js | 19 +++ .../javascripts/discourse/app/routes/users.js | 10 +- .../templates/components/directory-item.hbs | 17 +-- .../templates/components/directory-table.hbs | 24 ++++ .../components/table-header-toggle.hbs | 5 +- .../mobile/components/directory-item.hbs | 36 ++++-- .../discourse/app/templates/mobile/users.hbs | 9 +- .../modal/edit-user-directory-columns.hbs | 48 ++++++++ .../discourse/app/templates/users.hbs | 30 ++--- .../tests/acceptance/mobile-users-test.js | 1 + .../discourse/tests/acceptance/users-test.js | 111 +++++++++++++++++- .../tests/fixtures/directory-fixtures.js | 7 +- .../tests/helpers/create-pretender.js | 98 ++++++++++++++++ .../discourse/tests/setup-tests.js | 6 + .../stylesheets/common/base/directory.scss | 64 ++++++++++ app/assets/stylesheets/mobile/directory.scss | 11 ++ .../admin/user_fields_controller.rb | 3 + app/controllers/application_controller.rb | 15 +++ .../directory_columns_controller.rb | 62 ++++++++++ app/controllers/directory_items_controller.rb | 17 ++- app/models/directory_column.rb | 5 + app/models/user_field.rb | 1 + .../directory_column_serializer.rb | 13 ++ app/serializers/directory_item_serializer.rb | 11 +- config/locales/client.en.yml | 4 + config/routes.rb | 2 + ...20210527131318_create_directory_columns.rb | 41 +++++++ .../admin/user_fields_controller_spec.rb | 15 +++ .../directory_columns_controller_spec.rb | 74 ++++++++++++ 37 files changed, 890 insertions(+), 64 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/components/directory-table.js create mode 100644 app/assets/javascripts/discourse/app/controllers/edit-user-directory-columns.js create mode 100644 app/assets/javascripts/discourse/app/helpers/directory-item-label.js create mode 100644 app/assets/javascripts/discourse/app/helpers/directory-item-user-field-value.js create mode 100644 app/assets/javascripts/discourse/app/helpers/directory-item-value.js create mode 100644 app/assets/javascripts/discourse/app/helpers/directory-table-header-title.js create mode 100644 app/assets/javascripts/discourse/app/templates/components/directory-table.hbs create mode 100644 app/assets/javascripts/discourse/app/templates/modal/edit-user-directory-columns.hbs create mode 100644 app/controllers/directory_columns_controller.rb create mode 100644 app/models/directory_column.rb create mode 100644 app/serializers/directory_column_serializer.rb create mode 100644 db/migrate/20210527131318_create_directory_columns.rb create mode 100644 spec/requests/directory_columns_controller_spec.rb 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}} + + {{/if}} + + + {{#each items as |item|}} + {{directory-item item=item columns=columns showTimeRead=showTimeRead}} + {{/each}} + +
{{i18n "directory.time_read"}}
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 @@ -{{columnIcon}}{{title}}{{chevronIcon}} + + {{directory-table-header-title field=field labelKey=labelKey icon=icon translated=translated}} + {{chevronIcon}} + 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}} - - {{/if}} - - - {{#each model as |item|}} - {{directory-item item=item showTimeRead=showTimeRead}} - {{/each}} - -
{{i18n "directory.time_read"}}
- + {{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