Skip to content

plg_groups_members: order member roles and save canned deny replies - #1928

Open
denphi wants to merge 2 commits into
hubzero:2.4-mainfrom
denphi:feature/groups-members-role-order-deny-responses
Open

denphi wants to merge 2 commits into
hubzero:2.4-mainfrom
denphi:feature/groups-members-role-order-deny-responses

Conversation

@denphi

@denphi denphi commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Two changes to the group accept/decline screen, both for managers.

Member roles listed in the sidebar had no order at all: the query carried no ORDER BY, so they came back in creation order. #__xgroups_roles gains an ordering column, and a manager can drag roles into place, saved over AJAX. A "Sort automatically" button applies the order these lists are usually kept in - plain names alphabetically, then names holding a year in date order, reading months, seasons ("Fall 2025") and prefixes ("Class of 2026"). New roles are appended rather than jumping ahead of an arranged list, and the sidebar, each member's role list, the assign-role form and the messages plugin's role picker all read the same order. Existing roles start at 0 and so list by name until a manager sorts them.

Denying membership offered one empty textarea, retyped for every applicant. A group's managers can now save replies they send often and pick one from the deny form, editing it before it goes out. They live in the group's params, so no new table, and the email path is unchanged.

Two changes to the group accept/decline screen, both for managers.

Member roles listed in the sidebar had no order at all: the query carried
no ORDER BY, so they came back in creation order. `#__xgroups_roles` gains
an `ordering` column, and a manager can drag roles into place, saved over
AJAX. A "Sort automatically" button applies the order these lists are
usually kept in - plain names alphabetically, then names holding a year in
date order, reading months, seasons ("Fall 2025") and prefixes ("Class of
2026"). New roles are appended rather than jumping ahead of an arranged
list, and the sidebar, each member's role list, the assign-role form and
the messages plugin's role picker all read the same order. Existing roles
start at 0 and so list by name until a manager sorts them.

Denying membership offered one empty textarea, retyped for every applicant.
A group's managers can now save replies they send often and pick one from
the deny form, editing it before it goes out. They live in the group's
params, so no new table, and the email path is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@denphi
denphi requested a review from nkissebe as a code owner September 16, 2026 19:01
Comment thread core/plugins/groups/members/members.php Outdated
$params->set('deny_responses', $responses);

$this->group->set('params', $params->toString());
$this->group->update();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: mediumGroup::update() returns false when its UPDATE fails, but the return value is dropped here and the manager is redirected with PLG_GROUPS_MEMBERS_DENY_RESPONSES_SAVED ("Canned responses saved.") regardless.

This matters more than usual for this feature because #__xgroups.params is a text column (65,535 bytes) and the responses are unbounded free text — views/responses/tmpl/default.php puts no maxlength on the textarea and savedenyresponses() caps neither the length of a response nor how many there are. Concrete scenario: a manager pastes a few long boilerplate rejection letters. On a strict-mode server the UPDATE is rejected, update() returns false, and the manager is told the responses were saved when nothing was written. On a non-strict server MySQL silently truncates the column mid-JSON, so the whole params blob becomes unparsable and the group loses every other setting stored there (join policy, plugin access, discoverability, membership terms) — again while reporting success.

Suggest checking the return value and refusing an oversized payload up front rather than letting the column truncate, e.g. serialize first, bail with an error if strlen() > 65535, and re-render the form (handing back what was typed) when update() returns false.

Comment thread core/plugins/groups/members/members.php Outdated
$gid = (int) $this->group->get('gidNumber');

$ordering = 1;
foreach (Request::getArray('roles', array(), 'post') as $id)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: low$response['success'] is set to true unconditionally once auth and the token pass, even when the loop did no useful work.

roles[] is never validated: (int) $id turns junk into 0, and 0 can never match a row, so the UPDATEs quietly affect nothing. jQuery UI's serialize() makes this reachable rather than theoretical — when it can't match any element ids against its expression it emits the single pair roles[]= (see the if (!str.length && o.key) branch in core/assets/js/jquery.ui.js). PHP then receives [''], every id casts to 0, no row is touched, and the handler answers {"success":true}. The JS in members.js takes that as confirmation and leaves the dragged order on screen, so the manager believes the new order was saved; it reverts on the next page load.

Filtering the ids and treating an empty list as a failure makes the silent case visible:

$ids = array_values(array_filter(array_map('intval', Request::getArray('roles', array(), 'post'))));

if (empty($ids))
{
	$response['message'] = Lang::txt('PLG_GROUPS_MEMBERS_ROLE_ORDER_ERROR');
}
else
{
	// ... existing loop over $ids, then $response['success'] = true;
}

&& !$this->db->tableHasField('#__xgroups_roles', 'ordering'))
{
$query = "ALTER TABLE `#__xgroups_roles`
ADD COLUMN `ordering` int(11) NOT NULL DEFAULT 0 AFTER `name`";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: low — the matching column is missing from core/bootstrap/Install/sql/mysql/schema.sql, which still declares #__xgroups_roles as (id, gidNumber, name, permissions) (around line 5954).

That file is the canonical fresh-install schema, and the convention in this tree is to change both: the sibling feature in the same area (45f83df, PR #1915) shipped Migration20260810000000ComGroups.php and a schema.sql update in the same commit. Leaving it out means the file no longer describes a current database, so anything that builds or diffs a schema straight from it (test fixtures, a restored baseline, the next regeneration) is missing ordering, and every query added by this PR — getRoles(), the per-member query in views/browse/tmpl/default.php, and plg_groups_messages — is a fatal Unknown column 'ordering' until the migration runs against it.

Adding `ordering` int(11) NOT NULL DEFAULT 0 after `name` in schema.sql keeps the two in step.

->limit(1)
->row();

$this->set('ordering', (int) $last->get('ordering') + 1);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: low — this breaks the "roles list by name until a manager sorts them" promise the migration docblock makes, the first time a role is added.

After the migration every role sits at ordering = 0, so getRoles()'s ORDER BY ordering ASC, name ASC lists a never-reordered group alphabetically. The moment a manager adds one more role, the max here is 0, the new role gets 1, and it lands below every existing role while the rest stay alphabetical — a group that never touched ordering now has one role stuck at the bottom, with no UI hint why. Concrete: a group with roles "Alumni", "Faculty", "Staff" (all at 0) adds "Advisors"; the sidebar shows Alumni, Faculty, Staff, Advisors.

The method comment says a new role should not jump ahead of "roles a manager has already put in order" — in a group where nothing has been ordered there is no arranged list to protect. Only appending when the group actually has an order satisfies both intents:

$last = self::blank()
	->whereEquals('gidNumber', (int) $this->get('gidNumber'))
	->order('ordering', 'desc')
	->limit(1)
	->row();

// A group whose roles have never been ordered keeps listing by name
$this->set('ordering', ((int) $last->get('ordering') > 0)
	? (int) $last->get('ordering') + 1
	: 0);

Flagging rather than asserting — if new-roles-always-last is the deliberate choice, then the migration docblock is the thing that needs rewording.

</select>
</label>
<p class="hint">
<a href="<?php echo Route::url($manageUrl); ?>"><?php echo Lang::txt('PLG_GROUPS_MEMBERS_DENY_RESPONSES_MANAGE'); ?></a>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: low — this link navigates away from the deny form in the same tab, silently discarding whatever the manager has already typed into #reason.

The form carries the selected users through (users[] into denyresponses(), back out via the redirect in savedenyresponses()), but the reply textarea below is not carried anywhere. Concrete scenario: a manager writes a paragraph of custom explanation, realises it is worth keeping for next time, clicks "Manage canned responses", pastes it in and saves — the redirect lands them back on a fresh deny form and the text they typed in the original textarea is gone. It is recoverable only because they happened to have pasted it into the response form; if they click the link before pasting, it is lost outright.

Same applies to the "Add some" link on line 57 in the empty case, though there the textarea is more likely to still be empty. Cheapest fix is target="_blank" on these two links so the deny form stays put; stashing #reason in sessionStorage before navigating and restoring it on load would preserve the single-tab flow.

Review follow-ups on the member role ordering / canned deny reply work:

- savedenyresponses() serialized the responses into `#__xgroups`.`params`
  (a TEXT column) without a size check and ignored update()'s return, so
  an oversized or failed write still reported success. An overflow was
  the worse case: MySQL truncates the JSON mid-blob and the next
  Registry::parse() rejects it outright, silently resetting every other
  group setting. Refuse a payload over 65535 bytes, check the write, and
  re-render the form with an error on either failure. denyresponses()
  takes the typed responses back so nothing the manager wrote is lost.

- reorderroles() answered success: true unconditionally. jQuery UI's
  serialize() emits a bare `roles[]=` when it matches no element, which
  casts to 0 and updates no row, leaving the dragged order on screen and
  the database untouched. Int-filter the ids and report an error when
  none survive.

- schema.sql did not carry the `ordering` column the migration adds, so
  a fresh install lacked the column getRoles() and the browse template
  now rely on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants