FIX: Better handling of Group model state (#8356)

The group card and group members page were affecting each other and were
leaking members list and the query parameters which led to bad UX
experience and sub-optimal performance (client made more queries because
it was loading fewer members).

This commit refactors the group model to make it more consistent, remove
dead code, move error handling outside of model.
This commit is contained in:
Dan Ungureanu 2019-11-18 14:59:28 +02:00 committed by GitHub
parent acb8d6f946
commit 352d43b101
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 188 additions and 325 deletions

View File

@ -5,6 +5,7 @@ import { default as discourseComputed } from "discourse-common/utils/decorators"
import CardContentsBase from "discourse/mixins/card-contents-base"; import CardContentsBase from "discourse/mixins/card-contents-base";
import CleansUp from "discourse/mixins/cleans-up"; import CleansUp from "discourse/mixins/cleans-up";
import { groupPath } from "discourse/lib/url"; import { groupPath } from "discourse/lib/url";
import { Promise } from "rsvp";
const maxMembersToDisplay = 10; const maxMembersToDisplay = 10;
@ -55,8 +56,9 @@ export default Component.extend(CardContentsBase, CleansUp, {
if (!group.flair_url && !group.flair_bg_color) { if (!group.flair_url && !group.flair_bg_color) {
group.set("flair_url", "fa-users"); group.set("flair_url", "fa-users");
} }
group.set("limit", maxMembersToDisplay); return group.members.length < maxMembersToDisplay
return group.findMembers(); ? group.findMembers({ limit: maxMembersToDisplay }, true)
: Promise.resolve();
}) })
.catch(() => this._close()) .catch(() => this._close())
.finally(() => this.set("loading", null)); .finally(() => this.set("loading", null));

View File

@ -1,91 +0,0 @@
import discourseComputed from "discourse-common/utils/decorators";
import { isEmpty } from "@ember/utils";
import { lte } from "@ember/object/computed";
import Component from "@ember/component";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { propertyEqual } from "discourse/lib/computed";
export default Component.extend({
classNames: ["group-members-input"],
addButton: true,
@discourseComputed("model.limit", "model.offset", "model.user_count")
currentPage(limit, offset, userCount) {
if (userCount === 0) {
return 0;
}
return Math.floor(offset / limit) + 1;
},
@discourseComputed("model.limit", "model.user_count")
totalPages(limit, userCount) {
if (userCount === 0) {
return 0;
}
return Math.ceil(userCount / limit);
},
@discourseComputed("model.usernames")
disableAddButton(usernames) {
return !usernames || !(usernames.length > 0);
},
showingFirst: lte("currentPage", 1),
showingLast: propertyEqual("currentPage", "totalPages"),
actions: {
next() {
if (this.showingLast) {
return;
}
const group = this.model;
const offset = Math.min(
group.get("offset") + group.get("limit"),
group.get("user_count")
);
group.set("offset", offset);
return group.findMembers();
},
previous() {
if (this.showingFirst) {
return;
}
const group = this.model;
const offset = Math.max(group.get("offset") - group.get("limit"), 0);
group.set("offset", offset);
return group.findMembers();
},
addMembers() {
if (isEmpty(this.get("model.usernames"))) {
return;
}
this.model.addMembers(this.get("model.usernames")).catch(popupAjaxError);
this.set("model.usernames", null);
},
removeMember(member) {
const message = I18n.t("groups.manage.delete_member_confirm", {
username: member.get("username"),
group: this.get("model.name")
});
return bootbox.confirm(
message,
I18n.t("no_value"),
I18n.t("yes_value"),
confirm => {
if (confirm) {
this.model.removeMember(member);
}
}
);
}
}
});

View File

@ -6,6 +6,7 @@ import {
observes observes
} from "discourse-common/utils/decorators"; } from "discourse-common/utils/decorators";
import Group from "discourse/models/group"; import Group from "discourse/models/group";
import { popupAjaxError } from "discourse/lib/ajax-error";
import discourseDebounce from "discourse/lib/debounce"; import discourseDebounce from "discourse/lib/debounce";
import EmberObject from "@ember/object"; import EmberObject from "@ember/object";
@ -68,7 +69,8 @@ export default Component.extend({
name = this.nameInput; name = this.nameInput;
if (isEmpty(name)) return; if (isEmpty(name)) return;
Group.checkName(name).then(response => { Group.checkName(name)
.then(response => {
const validationName = "uniqueNameValidation"; const validationName = "uniqueNameValidation";
if (response.available) { if (response.available) {
@ -93,7 +95,8 @@ export default Component.extend({
this.set(validationName, this._failedInputValidation(reason)); this.set(validationName, this._failedInputValidation(reason));
} }
}); })
.catch(popupAjaxError);
}, 500), }, 500),
_failedInputValidation(reason) { _failedInputValidation(reason) {

View File

@ -1,27 +1,25 @@
import Controller, { inject } from "@ember/controller";
import { alias } from "@ember/object/computed"; import { alias } from "@ember/object/computed";
import { inject } from "@ember/controller";
import Controller from "@ember/controller";
import { popupAjaxError } from "discourse/lib/ajax-error";
import Group from "discourse/models/group";
import { import {
default as discourseComputed, default as discourseComputed,
observes observes
} from "discourse-common/utils/decorators"; } from "discourse-common/utils/decorators";
import { popupAjaxError } from "discourse/lib/ajax-error";
import discourseDebounce from "discourse/lib/debounce"; import discourseDebounce from "discourse/lib/debounce";
import User from "discourse/models/user";
export default Controller.extend({ export default Controller.extend({
application: inject(),
queryParams: ["order", "desc", "filter"], queryParams: ["order", "desc", "filter"],
order: "", order: "",
desc: null, desc: null,
loading: false,
limit: null,
offset: null,
isOwner: alias("model.is_group_owner"),
showActions: false,
filter: null, filter: null,
filterInput: null, filterInput: null,
application: inject(),
loading: false,
isOwner: alias("model.is_group_owner"),
showActions: false,
@observes("filterInput") @observes("filterInput")
_setFilter: discourseDebounce(function() { _setFilter: discourseDebounce(function() {
@ -29,19 +27,33 @@ export default Controller.extend({
}, 500), }, 500),
@observes("order", "desc", "filter") @observes("order", "desc", "filter")
refreshMembers() { _filtersChanged() {
this.set("loading", true); this.findMembers(true);
const model = this.model; },
if (model && model.can_see_members) { findMembers(refresh) {
model.findMembers(this.memberParams).finally(() => { if (this.loading) {
return;
}
const model = this.model;
if (!model) {
return;
}
if (!refresh && model.members.length >= model.user_count) {
this.set("application.showFooter", true);
return;
}
this.set("loading", true);
model.findMembers(this.memberParams, refresh).finally(() => {
this.set( this.set(
"application.showFooter", "application.showFooter",
model.members.length >= model.user_count model.members.length >= model.user_count
); );
this.set("loading", false); this.set("loading", false);
}); });
}
}, },
@discourseComputed("order", "desc", "filter") @discourseComputed("order", "desc", "filter")
@ -49,7 +61,7 @@ export default Controller.extend({
return { order, desc, filter }; return { order, desc, filter };
}, },
@discourseComputed("model.members") @discourseComputed("model.members.[]")
hasMembers(members) { hasMembers(members) {
return members && members.length > 0; return members && members.length > 0;
}, },
@ -69,6 +81,10 @@ export default Controller.extend({
}, },
actions: { actions: {
loadMore() {
this.findMembers();
},
toggleActions() { toggleActions() {
this.toggleProperty("showActions"); this.toggleProperty("showActions");
}, },
@ -93,38 +109,6 @@ export default Controller.extend({
.then(() => this.set("usernames", [])) .then(() => this.set("usernames", []))
.catch(popupAjaxError); .catch(popupAjaxError);
} }
},
loadMore() {
if (this.loading) {
return;
}
if (this.get("model.members.length") >= this.get("model.user_count")) {
this.set("application.showFooter", true);
return;
}
this.set("loading", true);
Group.loadMembers(
this.get("model.name"),
this.get("model.members.length"),
this.limit,
{ order: this.order, desc: this.desc }
).then(result => {
this.get("model.members").addObjects(
result.members.map(member => User.create(member))
);
this.setProperties({
loading: false,
user_count: result.meta.total,
limit: result.meta.limit,
offset: Math.min(
result.meta.offset + result.meta.limit,
result.meta.total
)
});
});
} }
} }
}); });

View File

@ -1,25 +1,23 @@
import { inject } from "@ember/controller"; import Controller, { inject } from "@ember/controller";
import Controller from "@ember/controller";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import Group from "discourse/models/group";
import { import {
default as discourseComputed, default as discourseComputed,
observes observes
} from "discourse-common/utils/decorators"; } from "discourse-common/utils/decorators";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import discourseDebounce from "discourse/lib/debounce"; import discourseDebounce from "discourse/lib/debounce";
import User from "discourse/models/user";
export default Controller.extend({ export default Controller.extend({
application: inject(),
queryParams: ["order", "desc", "filter"], queryParams: ["order", "desc", "filter"],
order: "", order: "",
desc: null, desc: null,
loading: false,
limit: null,
offset: null,
filter: null, filter: null,
filterInput: null, filterInput: null,
application: inject(),
loading: false,
@observes("filterInput") @observes("filterInput")
_setFilter: discourseDebounce(function() { _setFilter: discourseDebounce(function() {
@ -27,51 +25,41 @@ export default Controller.extend({
}, 500), }, 500),
@observes("order", "desc", "filter") @observes("order", "desc", "filter")
refreshRequesters(force) { _filtersChanged() {
if (this.loading || !this.model) { this.findRequesters(true);
},
findRequesters(refresh) {
if (this.loading) {
return; return;
} }
if ( const model = this.model;
!force && if (!model) {
this.count && return;
this.get("model.requesters.length") >= this.count }
) {
if (!refresh && model.members.length >= model.user_count) {
this.set("application.showFooter", true); this.set("application.showFooter", true);
return; return;
} }
this.set("loading", true); this.set("loading", true);
this.set("application.showFooter", false); model.findRequesters(this.memberParams, refresh).finally(() => {
this.set(
Group.loadMembers( "application.showFooter",
this.get("model.name"), model.requesters.length >= model.user_count
force ? 0 : this.get("model.requesters.length"), );
this.limit, this.set("loading", false);
{
order: this.order,
desc: this.desc,
filter: this.filter,
requesters: true
}
).then(result => {
const requesters = (!force && this.get("model.requesters")) || [];
requesters.addObjects(result.members.map(m => User.create(m)));
this.set("model.requesters", requesters);
this.setProperties({
loading: false,
count: result.meta.total,
limit: result.meta.limit,
offset: Math.min(
result.meta.offset + result.meta.limit,
result.meta.total
)
});
}); });
}, },
@discourseComputed("model.requesters") @discourseComputed("order", "desc", "filter")
memberParams(order, desc, filter) {
return { order, desc, filter };
},
@discourseComputed("model.requesters.[]")
hasRequesters(requesters) { hasRequesters(requesters) {
return requesters && requesters.length > 0; return requesters && requesters.length > 0;
}, },
@ -94,7 +82,7 @@ export default Controller.extend({
actions: { actions: {
loadMore() { loadMore() {
this.refreshRequesters(); this.findRequesters();
}, },
acceptRequest(user) { acceptRequest(user) {

View File

@ -21,10 +21,17 @@ export default Controller.extend({
@discourseComputed( @discourseComputed(
"showMessages", "showMessages",
"model.user_count", "model.user_count",
"model.request_count",
"canManageGroup", "canManageGroup",
"model.allow_membership_requests" "model.allow_membership_requests"
) )
tabs(showMessages, userCount, canManageGroup, allowMembershipRequests) { tabs(
showMessages,
userCount,
requestCount,
canManageGroup,
allowMembershipRequests
) {
const membersTab = Tab.create({ const membersTab = Tab.create({
name: "members", name: "members",
route: "group.index", route: "group.index",
@ -41,7 +48,8 @@ export default Controller.extend({
Tab.create({ Tab.create({
name: "requests", name: "requests",
i18nKey: "requests.title", i18nKey: "requests.title",
icon: "user-plus" icon: "user-plus",
count: requestCount
}) })
); );
} }

View File

@ -1,31 +1,31 @@
import EmberObject from "@ember/object";
import { equal } from "@ember/object/computed";
import { isEmpty } from "@ember/utils"; import { isEmpty } from "@ember/utils";
import { notEmpty, equal } from "@ember/object/computed";
import { ajax } from "discourse/lib/ajax";
import { import {
default as discourseComputed, default as discourseComputed,
observes observes
} from "discourse-common/utils/decorators"; } from "discourse-common/utils/decorators";
import { ajax } from "discourse/lib/ajax";
import Category from "discourse/models/category";
import GroupHistory from "discourse/models/group-history"; import GroupHistory from "discourse/models/group-history";
import RestModel from "discourse/models/rest"; import RestModel from "discourse/models/rest";
import Category from "discourse/models/category";
import User from "discourse/models/user";
import Topic from "discourse/models/topic"; import Topic from "discourse/models/topic";
import { popupAjaxError } from "discourse/lib/ajax-error"; import User from "discourse/models/user";
import EmberObject from "@ember/object";
const Group = RestModel.extend({ const Group = RestModel.extend({
limit: 50,
offset: 0,
user_count: 0, user_count: 0,
limit: null,
offset: null,
request_count: 0,
requestersLimit: null,
requestersOffset: null,
init() { init() {
this._super(...arguments); this._super(...arguments);
this.setProperties({ members: [], requesters: [] });
this.set("owners", []);
}, },
hasOwners: notEmpty("owners"),
@discourseComputed("automatic_membership_email_domains") @discourseComputed("automatic_membership_email_domains")
emailDomains(value) { emailDomains(value) {
return isEmpty(value) ? "" : value; return isEmpty(value) ? "" : value;
@ -36,50 +36,76 @@ const Group = RestModel.extend({
return automatic ? "automatic" : "custom"; return automatic ? "automatic" : "custom";
}, },
@discourseComputed("user_count") findMembers(params, refresh) {
userCountDisplay(userCount) {
// don't display zero its ugly
if (userCount > 0) {
return userCount;
}
},
findMembers(params) {
if (isEmpty(this.name) || !this.can_see_members) { if (isEmpty(this.name) || !this.can_see_members) {
return; return Ember.RSVP.Promise.reject();
} }
const offset = Math.min(this.user_count, Math.max(this.offset, 0)); if (refresh) {
this.setProperties({ limit: null, offset: null });
}
return Group.loadMembers(this.name, offset, this.limit, params).then( params = Object.assign(
result => { { offset: (this.offset || 0) + (this.limit || 0) },
const ownerIds = {}; params
result.owners.forEach(owner => (ownerIds[owner.id] = true)); );
return Group.loadMembers(this.name, params).then(result => {
const ownerIds = new Set();
result.owners.forEach(owner => ownerIds.add(owner.id));
const members = refresh ? [] : this.members;
members.pushObjects(
result.members.map(member => {
member.owner = ownerIds.has(member.id);
return User.create(member);
})
);
this.setProperties({ this.setProperties({
members,
user_count: result.meta.total, user_count: result.meta.total,
limit: result.meta.limit, limit: result.meta.limit,
offset: result.meta.offset, offset: result.meta.offset
members: result.members.map(member => {
if (ownerIds[member.id]) {
member.owner = true;
}
return User.create(member);
}),
owners: result.owners.map(owner => User.create(owner))
}); });
});
},
findRequesters(params, refresh) {
if (isEmpty(this.name) || !this.can_see_members) {
return Ember.RSVP.Promise.reject();
} }
if (refresh) {
this.setProperties({ requestersOffset: null, requestersLimit: null });
}
params = Object.assign(
{
offset: (this.requestersOffset || 0) + (this.requestersLimit || 0),
requesters: true
},
params
); );
return Group.loadMembers(this.name, params).then(result => {
const requesters = refresh ? [] : this.requesters;
requesters.pushObjects(result.members.map(m => User.create(m)));
this.setProperties({
requesters,
request_count: result.meta.total,
requestersLimit: result.meta.limit,
requestersOffset: result.meta.offset
});
});
}, },
removeOwner(member) { removeOwner(member) {
return ajax(`/admin/groups/${this.id}/owners.json`, { return ajax(`/admin/groups/${this.id}/owners.json`, {
type: "DELETE", type: "DELETE",
data: { user_id: member.id } data: { user_id: member.id }
}).then(() => { }).then(() => this.findMembers());
// reload member list
this.findMembers();
});
}, },
removeMember(member, params) { removeMember(member, params) {
@ -272,16 +298,8 @@ Group.reopenClass({
); );
}, },
loadMembers(name, offset, limit, params) { loadMembers(name, opts) {
return ajax(`/groups/${name}/members.json`, { return ajax(`/groups/${name}/members.json`, { data: opts });
data: Object.assign(
{
limit: limit || 50,
offset: offset || 0
},
params || {}
)
});
}, },
mentionable(name) { mentionable(name) {
@ -293,9 +311,7 @@ Group.reopenClass({
}, },
checkName(name) { checkName(name) {
return ajax("/groups/check-name", { return ajax("/groups/check-name", { data: { group_name: name } });
data: { group_name: name }
}).catch(popupAjaxError);
} }
}); });

View File

@ -19,7 +19,7 @@ export default DiscourseRoute.extend({
filterInput: this._params.filter filterInput: this._params.filter
}); });
controller.refreshMembers(); controller.findMembers(true);
}, },
actions: { actions: {

View File

@ -18,6 +18,6 @@ export default DiscourseRoute.extend({
filterInput: this._params.filter filterInput: this._params.filter
}); });
controller.refreshRequesters(true); controller.findRequesters(true);
} }
}); });

View File

@ -1,30 +0,0 @@
<label>{{i18n 'groups.members.title'}} ({{model.user_count}})</label>
{{#if model.members}}
<div>
<a class="previous {{if showingFirst 'disabled'}}" {{action "previous"}}>{{d-icon "fast-backward"}}</a>
{{currentPage}}/{{totalPages}}
<a class="next {{if showingLast 'disabled'}}" {{action "next"}}>{{d-icon "fast-forward"}}</a>
</div>
<div class="ac-wrap clearfix">
{{#each model.members as |member|}}
{{group-member member=member automatic=model.automatic removeAction=(action "removeMember")}}
{{/each}}
</div>
{{/if}}
{{#unless model.automatic}}
<div class="group-members-input-selector">
{{user-selector usernames=model.usernames
placeholderKey="groups.selector_placeholder"
id="member-selector"}}
{{#if addButton}}
{{d-button action=(action "addMembers")
class="add"
icon="plus"
disabled=disableAddButton
label="groups.manage.add_members"}}
{{/if}}
</div>
{{/unless}}

View File

@ -1,9 +0,0 @@
.group-members-input {
.group-members-input-selector {
margin-top: 10px;
.add {
margin-top: 7px;
}
}
}

View File

@ -208,20 +208,11 @@ class GroupsController < ApplicationController
guardian.ensure_can_see_group_members!(group) guardian.ensure_can_see_group_members!(group)
limit = (params[:limit] || 20).to_i limit = (params[:limit] || 50).to_i
offset = params[:offset].to_i offset = params[:offset].to_i
if limit < 0 raise Discourse::InvalidParameters.new(:limit) if limit < 0 || limit > 1000
raise Discourse::InvalidParameters.new(:limit) raise Discourse::InvalidParameters.new(:offset) if offset < 0
end
if limit > 1000
raise Discourse::InvalidParameters.new(:limit)
end
if offset < 0
raise Discourse::InvalidParameters.new(:offset)
end
dir = (params[:desc] && !params[:desc].blank?) ? 'DESC' : 'ASC' dir = (params[:desc] && !params[:desc].blank?) ? 'DESC' : 'ASC'
order = "" order = ""

View File

@ -37,6 +37,7 @@ acceptance("Group Requests", {
is_group_user: true, is_group_user: true,
is_group_owner: true, is_group_owner: true,
is_group_owner_display: true, is_group_owner_display: true,
can_see_members: true,
mentionable: false, mentionable: false,
messageable: false messageable: false
}, },