From 229c09187b5008b07ff0eed24c75ca50e3e1645f Mon Sep 17 00:00:00 2001 From: Daniel Mejia Date: Wed, 16 Sep 2026 14:58:13 -0400 Subject: [PATCH 1/2] plg_groups_members: order member roles and save canned deny replies 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) --- .../Migration20260915000000ComGroups.php | 49 +++ core/components/com_groups/models/role.php | 13 + .../groups/members/assets/css/members.css | 49 +++ .../groups/members/assets/js/members.js | 87 +++++ .../en-GB/en-GB.plg_groups_members.ini | 20 ++ core/plugins/groups/members/members.php | 327 +++++++++++++++++- .../members/views/browse/tmpl/default.php | 39 ++- .../members/views/deny/tmpl/default.php | 27 ++ .../members/views/responses/tmpl/default.php | 84 +++++ core/plugins/groups/messages/messages.php | 2 +- 10 files changed, 686 insertions(+), 11 deletions(-) create mode 100644 core/components/com_groups/migrations/Migration20260915000000ComGroups.php create mode 100644 core/plugins/groups/members/views/responses/tmpl/default.php diff --git a/core/components/com_groups/migrations/Migration20260915000000ComGroups.php b/core/components/com_groups/migrations/Migration20260915000000ComGroups.php new file mode 100644 index 00000000000..9e187a240bc --- /dev/null +++ b/core/components/com_groups/migrations/Migration20260915000000ComGroups.php @@ -0,0 +1,49 @@ +db->tableExists('#__xgroups_roles') + && !$this->db->tableHasField('#__xgroups_roles', 'ordering')) + { + $query = "ALTER TABLE `#__xgroups_roles` + ADD COLUMN `ordering` int(11) NOT NULL DEFAULT 0 AFTER `name`"; + $this->db->setQuery($query); + $this->db->query(); + } + } + + /** + * Down + **/ + public function down() + { + if ($this->db->tableExists('#__xgroups_roles') + && $this->db->tableHasField('#__xgroups_roles', 'ordering')) + { + $query = "ALTER TABLE `#__xgroups_roles` DROP COLUMN `ordering`"; + $this->db->setQuery($query); + $this->db->query(); + } + } +} diff --git a/core/components/com_groups/models/role.php b/core/components/com_groups/models/role.php index 080b6579b03..66eaea140d1 100644 --- a/core/components/com_groups/models/role.php +++ b/core/components/com_groups/models/role.php @@ -93,6 +93,19 @@ public function save() $this->set('permissions', json_encode($this->get('permissions'))); } + // A new role goes to the end of its group's list rather than jumping + // ahead of roles a manager has already put in order + if ($this->isNew() && !$this->get('ordering')) + { + $last = self::blank() + ->whereEquals('gidNumber', (int) $this->get('gidNumber')) + ->order('ordering', 'desc') + ->limit(1) + ->row(); + + $this->set('ordering', (int) $last->get('ordering') + 1); + } + return parent::save(); } diff --git a/core/plugins/groups/members/assets/css/members.css b/core/plugins/groups/members/assets/css/members.css index 3f08620f758..fe1cf380750 100644 --- a/core/plugins/groups/members/assets/css/members.css +++ b/core/plugins/groups/members/assets/css/members.css @@ -230,6 +230,55 @@ .aside a.edit-role:after { content: '\270E'; } +.aside .roles-order-hint { + color: #777; + font-size: 0.85em; +} +.aside .roles-sort { + margin: 0 0 0.5em; +} +.aside .roles-sort .btn { + font-size: 0.85em; + padding: 0.2em 0.6em; +} +.aside ul.roles.sortable li { + padding-left: 1.5em; +} +.aside ul.roles.sortable li.ui-sortable-helper { + background-color: #fff; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); +} +.aside .role-mover { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 1.5em; + cursor: move; + color: #999; + text-align: center; +} +.aside .role-mover:after { + content: '\2630'; + position: absolute; + top: 50%; + left: 0; + right: 0; + transform: translateY(-50%); +} +.aside .role-mover:hover { + color: #333; +} + +/* Canned deny responses */ +.deny-responses .deny-response { + border-bottom: 1px solid #ddd; + margin-bottom: 1em; + padding-bottom: 0.5em; +} +.deny-responses .deny-response-remove { + text-align: right; +} /* Message AJAX */ #sbox-content .hub-mail { diff --git a/core/plugins/groups/members/assets/js/members.js b/core/plugins/groups/members/assets/js/members.js index b9a815c976d..df0e6783bf9 100644 --- a/core/plugins/groups/members/assets/js/members.js +++ b/core/plugins/groups/members/assets/js/members.js @@ -89,4 +89,91 @@ jQuery(document).ready(function(jq){ } }); //end assign role pop ups + + // Managers drag member roles into the order the sidebar lists them + var roles = $('.aside ul.roles.sortable'); + if (roles.length && jQuery.ui && jQuery.ui.sortable) { + roles.sortable({ + handle: '.role-mover', + items: '> li', + axis: 'y', + cursor: 'move', + update: function (e, ui) { + var frm = $('form.roles-order'); + var data = frm.serialize() + '&' + roles.sortable('serialize', { key: 'roles[]' }); + + $.post(frm.attr('action'), data, function (response) { + if (!response || !response.success) { + roles.sortable('cancel'); + alert((response && response.message) || frm.attr('data-error')); + } + }, 'json').fail(function () { + roles.sortable('cancel'); + alert(frm.attr('data-error')); + }); + } + }); + } + + // Sorting replaces an order a manager may have arranged by hand + $('.roles-sort').on('submit', function (e) { + if (!confirm($(this).find('.sort-roles').attr('data-confirm'))) { + e.preventDefault(); + } + }); + + // Fill the deny reply from a canned response + var denyReason = $('#reason'); + var lastResponse = ''; + $('#deny-response').on('change', function () { + var text = $(this).find('option:selected').attr('data-text'); + + if (typeof text === 'undefined') { + return; + } + + // Don't silently throw away a reply the manager has written or edited + var current = $.trim(denyReason.val()); + if (current !== '' && current !== $.trim(lastResponse) && !confirm($(this).attr('data-confirm'))) { + return; + } + + denyReason.val(text); + lastResponse = text; + }); + + // Add and remove rows on the canned response form + $('.deny-responses').on('click', '.deny-response-add', function (e) { + e.preventDefault(); + + var list = $('.deny-response-list'); + var next = parseInt(list.attr('data-next'), 10); + var row = list.find('.deny-response').last().clone(); + + row.find('input, textarea').each(function () { + var field = $(this); + field.val(''); + field.attr('name', field.attr('name').replace(/\[\d+\]/, '[' + next + ']')); + field.attr('id', field.attr('id').replace(/-\d+-/, '-' + next + '-')); + }); + row.find('label').each(function () { + $(this).attr('for', $(this).attr('for').replace(/-\d+-/, '-' + next + '-')); + }); + + list.append(row).attr('data-next', next + 1); + row.find('input').first().focus(); + }); + + $('.deny-responses').on('click', '.deny-response-remove button', function (e) { + e.preventDefault(); + + var row = $(this).closest('.deny-response'); + + // Keep one row on the page to clone from; an emptied row isn't saved + if ($('.deny-response-list .deny-response').length > 1) { + row.remove(); + } else { + row.find('input, textarea').val(''); + } + }); }); diff --git a/core/plugins/groups/members/language/en-GB/en-GB.plg_groups_members.ini b/core/plugins/groups/members/language/en-GB/en-GB.plg_groups_members.ini index 79f858c1464..29ac332a31d 100644 --- a/core/plugins/groups/members/language/en-GB/en-GB.plg_groups_members.ini +++ b/core/plugins/groups/members/language/en-GB/en-GB.plg_groups_members.ini @@ -119,6 +119,19 @@ PLG_GROUPS_MEMBERS_DENY_EXPLANATION="If a user is denied membership by mistake, PLG_GROUPS_MEMBERS_DENY_REASON="Customize reply to the user(s)" PLG_GROUPS_MEMBERS_DENY_REASON_DEFAULT="Sorry, but we cannot grant you access to this group." PLG_GROUPS_MEMBERS_DENY_USERS="User(s)" +PLG_GROUPS_MEMBERS_DENY_RESPONSES="Canned Responses" +PLG_GROUPS_MEMBERS_DENY_RESPONSES_EXPLANATION="Save replies you send often when denying membership. Choose one on the deny form to fill in the reply, then edit it before sending if needed. Clear a response's text to remove it." +PLG_GROUPS_MEMBERS_DENY_RESPONSES_MANAGE="Manage canned responses" +PLG_GROUPS_MEMBERS_DENY_RESPONSES_NONE="This group has no canned responses." +PLG_GROUPS_MEMBERS_DENY_RESPONSES_ADD="Add some" +PLG_GROUPS_MEMBERS_DENY_RESPONSES_SAVED="Canned responses saved." +PLG_GROUPS_MEMBERS_DENY_RESPONSE_CHOOSE="Use a canned response" +PLG_GROUPS_MEMBERS_DENY_RESPONSE_NONE="— Select a response —" +PLG_GROUPS_MEMBERS_DENY_RESPONSE_REPLACE="Replace the reply you have written with this response?" +PLG_GROUPS_MEMBERS_DENY_RESPONSE_TITLE="Title" +PLG_GROUPS_MEMBERS_DENY_RESPONSE_TEXT="Response" +PLG_GROUPS_MEMBERS_DENY_RESPONSE_ADD="Add another response" +PLG_GROUPS_MEMBERS_DENY_RESPONSE_REMOVE="Remove" ; Cancel PLG_GROUPS_MEMBERS_CANCEL_REASON="Customize reply to the user(s)" @@ -168,6 +181,13 @@ PLG_GROUPS_MEMBERS_ROLE_SUCCESS="Group role successfully created or updated." PLG_GROUPS_MEMBERS_ROLE_REMOVE="Remove Role" PLG_GROUPS_MEMBERS_ROLE_EDIT="Edit Role" +PLG_GROUPS_MEMBERS_ROLE_ORDER_HINT="Drag roles by the handle to change their order." +PLG_GROUPS_MEMBERS_ROLE_ORDER_DRAG="Drag to reorder" +PLG_GROUPS_MEMBERS_ROLE_ORDER_ERROR="The new role order could not be saved. Please reload the page and try again." +PLG_GROUPS_MEMBERS_ROLE_ORDER_NOT_AUTHORIZED="Only group managers can change the order of roles." +PLG_GROUPS_MEMBERS_ROLE_SORT="Sort automatically" +PLG_GROUPS_MEMBERS_ROLE_SORT_CONFIRM="Sort every role: names first in alphabetical order, then years in date order. This replaces the current order." +PLG_GROUPS_MEMBERS_ROLE_SORT_DONE="Member roles sorted: names first, then years." ; Profile COM_MEMBERS_FIELD_VALUE_NONE="(none)" diff --git a/core/plugins/groups/members/members.php b/core/plugins/groups/members/members.php index ab92a175528..9861dbf6d29 100644 --- a/core/plugins/groups/members/members.php +++ b/core/plugins/groups/members/members.php @@ -243,10 +243,7 @@ public function onGroup($group, $option, $authorized, $limit, $limitstart, $acti } //get all member roles - $db = App::get('db'); - $sql = "SELECT * FROM `#__xgroups_roles` WHERE gidNumber=" . $db->quote($group->get('gidNumber')); - $db->setQuery($sql); - $view->member_roles = $db->loadAssocList(); + $view->member_roles = $this->getRoles(); $group_inviteemails = new \Hubzero\User\Group\InviteEmail(); $view->current_inviteemails = $group_inviteemails->getInviteEmails($this->group->get('gidNumber'), true); @@ -1174,6 +1171,7 @@ private function deny() $view->group = $this->group; $view->authorized = $this->authorized; $view->users = Request::getArray('users', array()); + $view->responses = $this->getDenyResponses(); foreach ($this->getErrors() as $error) { @@ -1183,6 +1181,140 @@ private function deny() $this->_output = $view->loadTemplate(); } + /** + * Get the canned replies a group's managers have saved for denying membership + * + * @return array List of arrays with 'title' and 'text' keys + */ + private function getDenyResponses() + { + $params = new Hubzero\Config\Registry($this->group->get('params')); + + $responses = array(); + foreach ((array) $params->get('deny_responses', array()) as $response) + { + // Stored as a JSON list, so entries read back as objects + $response = (array) $response; + + if (isset($response['text']) && trim($response['text']) !== '') + { + $responses[] = array( + 'title' => isset($response['title']) ? (string) $response['title'] : '', + 'text' => (string) $response['text'] + ); + } + } + + return $responses; + } + + /** + * Display a form for managing the group's canned deny responses + * + * @return void + */ + private function denyresponses() + { + if ($this->authorized != 'manager' && $this->authorized != 'admin') + { + return false; + } + + if ($this->membership_control == 0) + { + return false; + } + + Document::setTitle(Lang::txt(strtoupper($this->name)) . ': ' . $this->group->get('description') . ': ' . Lang::txt('PLG_GROUPS_MEMBERS_DENY_RESPONSES')); + + $view = $this->view('default', 'responses'); + $view->option = $this->_option; + $view->group = $this->group; + $view->authorized = $this->authorized; + $view->responses = $this->getDenyResponses(); + // Users on their way to being denied, so saving can return to that form + $view->users = Request::getArray('users', array()); + + foreach ($this->getErrors() as $error) + { + $view->setError($error); + } + + $this->_output = $view->loadTemplate(); + } + + /** + * Save the group's canned deny responses + * + * @return void + */ + private function savedenyresponses() + { + if ($this->authorized != 'manager' && $this->authorized != 'admin') + { + return false; + } + + if ($this->membership_control == 0) + { + return false; + } + + Request::checkToken(); + + $responses = array(); + foreach (Request::getArray('responses', array(), 'post') as $response) + { + if (!is_array($response)) + { + continue; + } + + $title = isset($response['title']) ? trim((string) $response['title']) : ''; + $text = isset($response['text']) ? trim((string) $response['text']) : ''; + + // A row with no text is how a response gets removed + if ($text === '') + { + continue; + } + + if ($title === '') + { + $title = mb_strimwidth(preg_replace('/\s+/', ' ', $text), 0, 60, '...'); + } + + $responses[] = array( + 'title' => $title, + 'text' => $text + ); + } + + $params = new Hubzero\Config\Registry($this->group->get('params')); + $params->set('deny_responses', $responses); + + $this->group->set('params', $params->toString()); + $this->group->update(); + + $url = 'index.php?option=' . $this->_option . '&cn=' . $this->group->get('cn') . '&active=members'; + + $users = array_filter(array_map('intval', Request::getArray('users', array(), 'post'))); + if (count($users)) + { + $url .= '&action=deny&users[]=' . implode('&users[]=', $users); + } + else + { + $url .= '&filter=pending'; + } + + App::redirect( + Route::url($url, false), + Lang::txt('PLG_GROUPS_MEMBERS_DENY_RESPONSES_SAVED'), + 'passed' + ); + } + /** * Deny one or more users membership * @@ -1431,6 +1563,189 @@ private function confirmcancel() App::redirect(Route::url('index.php?option=com_groups&cn=' . $this->group->get('cn') . '&active=members&filter=invitees'), '', '', true); } + /** + * Get the group's member roles in the order its managers have set + * + * @return array + */ + private function getRoles() + { + $db = App::get('db'); + $db->setQuery("SELECT * FROM `#__xgroups_roles` WHERE gidNumber=" . $db->quote($this->group->get('gidNumber')) . " ORDER BY `ordering` ASC, `name` ASC"); + + return $db->loadAssocList(); + } + + /** + * Read a date out of a role name, for sorting + * + * Groups name roles for graduation terms ("May 2026", "Fall 2025", + * "Class of 2026"), which have to sort by date rather than by name. + * Anything without a four digit year is not a term. + * + * @param string $name Role name + * @return integer Timestamp, or null when the name holds no date + */ + private function roleDate($name) + { + $value = trim($name); + + // Drop the wording groups put in front of a term + $value = preg_replace('/^(class of|cohort|graduat(?:ing|ion)|expected)\s+/i', '', $value); + + // Seasons sort as the month they start + $seasons = array( + 'spring' => 'March', + 'summer' => 'June', + 'fall' => 'September', + 'autumn' => 'September', + 'winter' => 'December' + ); + + $value = preg_replace_callback('/\b(spring|summer|fall|autumn|winter)\b/i', function ($m) use ($seasons) + { + return $seasons[strtolower($m[1])]; + }, $value); + + // Without a year this is a name, not a term + if (!preg_match('/(?:^|\D)((?:19|20)\d{2})(?:\D|$)/', $value, $year)) + { + return null; + } + + // Numeric terms ("05/2026") keep their month too + if (preg_match('#^(\d{1,2})[/-]((?:19|20)\d{2})$#', trim($value), $numeric) + && $numeric[1] >= 1 && $numeric[1] <= 12) + { + return strtotime($numeric[2] . '-' . str_pad($numeric[1], 2, '0', STR_PAD_LEFT) . '-01'); + } + + // A bare month and year ("May 2026") is the common case, so build a + // full date from the parts rather than trusting strtotime with it + if (preg_match('/([A-Za-z]+)?[\s,]*((?:19|20)\d{2})/', $value, $parts)) + { + $month = !empty($parts[1]) ? $parts[1] : 'January'; + $date = strtotime($month . ' 1 ' . $parts[2]); + + if ($date !== false) + { + return $date; + } + } + + $date = strtotime($value); + + return ($date === false) ? strtotime('January 1 ' . $year[1]) : $date; + } + + /** + * Put the group's member roles in a sensible default order + * + * Plain names (universities, departments) come first in alphabetical + * order, then graduation terms in date order. A manager can still drag + * anything afterwards. + * + * @return void + */ + private function sortroles() + { + if ($this->authorized != 'manager' || $this->membership_control == 0) + { + App::abort(403, Lang::txt('PLG_GROUPS_MEMBERS_ROLE_ORDER_NOT_AUTHORIZED')); + } + + Request::checkToken(); + + $names = array(); + $dates = array(); + + foreach ($this->getRoles() as $role) + { + $date = $this->roleDate($role['name']); + + if ($date === null) + { + $names[] = array('id' => (int) $role['id'], 'name' => $role['name']); + } + else + { + $dates[] = array('id' => (int) $role['id'], 'name' => $role['name'], 'date' => $date); + } + } + + usort($names, function ($a, $b) + { + return strcasecmp($a['name'], $b['name']); + }); + + usort($dates, function ($a, $b) + { + // Same term twice is a naming accident; fall back to the name + return ($a['date'] == $b['date']) + ? strcasecmp($a['name'], $b['name']) + : ($a['date'] < $b['date'] ? -1 : 1); + }); + + $db = App::get('db'); + $gid = (int) $this->group->get('gidNumber'); + + $ordering = 1; + foreach (array_merge($names, $dates) as $role) + { + $db->setQuery("UPDATE `#__xgroups_roles` SET `ordering`=" . $db->quote($ordering) . " WHERE `id`=" . $db->quote($role['id']) . " AND `gidNumber`=" . $db->quote($gid)); + $db->query(); + + $ordering++; + } + + App::redirect( + Route::url('index.php?option=' . $this->_option . '&cn=' . $this->group->get('cn') . '&active=members'), + Lang::txt('PLG_GROUPS_MEMBERS_ROLE_SORT_DONE'), + 'passed' + ); + } + + /** + * Save the order of the group's member roles (AJAX) + * + * @return void + */ + private function reorderroles() + { + $response = array('success' => false); + + if ($this->authorized != 'manager' || $this->membership_control == 0) + { + $response['message'] = Lang::txt('PLG_GROUPS_MEMBERS_ROLE_ORDER_NOT_AUTHORIZED'); + } + elseif (!App::get('session')->checkToken('post', true)) + { + $response['message'] = Lang::txt('JINVALID_TOKEN'); + } + else + { + $db = App::get('db'); + $gid = (int) $this->group->get('gidNumber'); + + $ordering = 1; + foreach (Request::getArray('roles', array(), 'post') as $id) + { + // The gidNumber condition keeps a request from reordering + // another group's roles + $db->setQuery("UPDATE `#__xgroups_roles` SET `ordering`=" . $db->quote($ordering) . " WHERE `id`=" . $db->quote((int) $id) . " AND `gidNumber`=" . $db->quote($gid)); + $db->query(); + + $ordering++; + } + + $response['success'] = true; + } + + header('Content-type: application/json'); + echo json_encode($response); + exit(); + } + /** * Add a member role * @@ -1620,9 +1935,7 @@ private function assignrole() // Cancel membership confirmation screen $view = $this->view('assign', 'role'); - $db = App::get('db'); - $db->setQuery("SELECT * FROM `#__xgroups_roles` WHERE gidNumber=" . $db->Quote($this->group->get('gidNumber'))); - $roles = $db->loadAssocList(); + $roles = $this->getRoles(); $view->option = $this->_option; $view->group = $this->group; diff --git a/core/plugins/groups/members/views/browse/tmpl/default.php b/core/plugins/groups/members/views/browse/tmpl/default.php index a42e4049ca3..955fed56b83 100644 --- a/core/plugins/groups/members/views/browse/tmpl/default.php +++ b/core/plugins/groups/members/views/browse/tmpl/default.php @@ -73,6 +73,11 @@ + authorized == 'manager' || $this->authorized == 'admin') : ?> + + + + @@ -353,7 +358,8 @@ "SELECT r.id, r.name, r.permissions FROM `#__xgroups_roles` as r LEFT JOIN `#__xgroups_member_roles` as m ON m.roleid=r.id - WHERE m.uidNumber=" . $db->quote($u->get('id')) . " AND r.gidNumber=" . $db->quote($this->group->gidNumber) + WHERE m.uidNumber=" . $db->quote($u->get('id')) . " AND r.gidNumber=" . $db->quote($this->group->gidNumber) . " + ORDER BY r.ordering ASC, r.name ASC" ); $roles = $db->loadAssocList(); @@ -577,10 +583,37 @@

member_roles) > 0) { ?> -
    + authorized == 'manager' && $this->membership_control == 1 && count($this->member_roles) > 1); + if ($canOrderRoles) + { + Html::behavior('framework', true); + } + ?> + +
    + +
    +

    +
    + + + + + + +
    + +
      member_roles as $role) { ?> role_filter) ? 'active' : ''; ?> -
    • +
    • + + + authorized == 'manager' && $this->membership_control == 1) : ?> diff --git a/core/plugins/groups/members/views/deny/tmpl/default.php b/core/plugins/groups/members/views/deny/tmpl/default.php index 156a99bcc09..1aad7da5bfc 100644 --- a/core/plugins/groups/members/views/deny/tmpl/default.php +++ b/core/plugins/groups/members/views/deny/tmpl/default.php @@ -31,6 +31,33 @@ + option . '&cn=' . $this->group->get('cn') . '&active=members&action=denyresponses'; + foreach ($this->users as $user) + { + $manageUrl .= '&users[]=' . (int) $user; + } + ?> + responses)) : ?> + +

      + +

      + +

      + + +

      + +