FEATURE: Add user custom fields to user directory (#13238)

This commit is contained in:
Mark VanLandingham 2021-06-07 12:34:01 -05:00 committed by GitHub
parent 2334c3622e
commit 0cba4d73c1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 890 additions and 64 deletions

View File

@ -5,4 +5,5 @@ export default Component.extend({
tagName: "tr",
classNameBindings: ["me"],
me: propertyEqual("item.user.id", "currentUser.id"),
columns: null,
});

View File

@ -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;
}
},
});

View File

@ -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);
}
},
});

View File

@ -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");
},
});

View File

@ -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);

View File

@ -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 }));
});

View File

@ -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(`<span class='user-field-value'>${content}</span>`);
}
);

View File

@ -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(
`<span class='number'>${number(args.item.get(args.column.name))}</span>`
);
});

View File

@ -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);
});

View File

@ -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: {

View File

@ -1,11 +1,14 @@
<td>{{user-info user=item.user}}</td>
<td>{{number item.likes_received}}</td>
<td>{{number item.likes_given}}</td>
<td>{{number item.topic_count}}</td>
<td>{{number item.post_count}}</td>
<td>{{number item.topics_entered}}</td>
<td>{{number item.posts_read}}</td>
<td>{{number item.days_visited}}</td>
{{#each columns as |column|}}
<td>
{{#if column.automatic}}
{{directory-item-value item=item column=column}}
{{else}}
{{directory-item-user-field-value item=item column=column}}
{{/if}}
</td>
{{/each}}
{{#if showTimeRead}}
<td><span class="time-read">{{format-duration item.time_read}}</span></td>
{{/if}}

View File

@ -0,0 +1,24 @@
<table>
<thead>
{{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}}
<th>{{i18n "directory.time_read"}}</th>
{{/if}}
</thead>
<tbody>
{{#each items as |item|}}
{{directory-item item=item columns=columns showTimeRead=showTimeRead}}
{{/each}}
</tbody>
</table>

View File

@ -1 +1,4 @@
<span class="header-contents">{{columnIcon}}{{title}}{{chevronIcon}}</span>
<span class="header-contents" id={{id}}>
{{directory-table-header-title field=field labelKey=labelKey icon=icon translated=translated}}
{{chevronIcon}}
</span>

View File

@ -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}}
<div class="user-stat">
<span class="value">
{{directory-item-value item=item column=column}}
</span>
<span class="label">
{{#if column.icon}}
{{d-icon column.icon}}
{{/if}}
{{mobile-directory-item-label item=item column=column}}
</span>
</div>
{{else}}
{{#if (get item.user.user_fields column.user_field_id)}}
<div class="user-stat">
<span class="value user-field">
{{directory-item-user-field-value item=item column=column}}
</span>
<span class="label">
{{column.name}}
</span>
</div>
{{/if}}
{{/if}}
{{/each}}
{{#if showTimeRead}}
{{user-stat value=item.time_read label="directory.time_read" type="duration"}}
{{/if}}

View File

@ -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}}
</div>
{{#conditional-loading-spinner condition=model.loading}}
{{#if model.length}}
<div class="total-rows">{{i18n "directory.total_rows" count=model.totalRows}}</div>
{{#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}}

View File

@ -0,0 +1,48 @@
{{#d-modal-body title="directory.edit_columns.title"}}
{{#if loading}}
{{loading-spinner size="large"}}
{{else}}
<div class="edit-directory-columns-container">
{{#each columns as |column|}}
<div class="edit-directory-column">
<div class="left-content">
<label class="column-name">
{{input type="checkbox" checked=column.enabled}}
{{#if column.automatic}}
{{directory-table-header-title field=column.name labelKey=labelKey icon=column.icon}}
{{else}}
{{directory-table-header-title field=column.user_field.name translated=true}}
{{/if}}
</label>
</div>
<div class="right-content">
{{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)
}}
</div>
</div>
{{/each}}
</div>
{{/if}}
{{/d-modal-body}}
<div class="modal-footer">
{{d-button
class="btn-primary"
label="directory.edit_columns.save"
action=(action "save")
}}
{{d-button
class="btn-secondary reset-to-default"
label="directory.edit_columns.reset_to_default"
action=(action "resetToDefault")
}}
</div>

View File

@ -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}}
</div>
</div>
{{#conditional-loading-spinner condition=isLoading}}
{{#if model.length}}
<table>
<thead>
{{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}}
<th>{{i18n "directory.time_read"}}</th>
{{/if}}
</thead>
<tbody>
{{#each model as |item|}}
{{directory-item item=item showTimeRead=showTimeRead}}
{{/each}}
</tbody>
</table>
{{directory-table items=model columns=columns showTimeRead=showTimeRead order=order asc=asc}}
{{conditional-loading-spinner condition=model.loadingMore}}
{{else}}
<div class="clearfix"></div>

View File

@ -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");

View File

@ -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",
]);
});
});

View File

@ -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,

View File

@ -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,
},
},
],
});
});
}

View File

@ -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");

View File

@ -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;
}

View File

@ -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%;
}

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -0,0 +1,5 @@
# frozen_string_literal: true
class DirectoryColumn < ActiveRecord::Base
belongs_to :user_field
end

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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' }

View File

@ -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

View File

@ -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

View File

@ -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