diff --git a/core/bootstrap/Install/sql/mysql/schema.sql b/core/bootstrap/Install/sql/mysql/schema.sql index 1e957d37e90..cf48c0b65e6 100644 --- a/core/bootstrap/Install/sql/mysql/schema.sql +++ b/core/bootstrap/Install/sql/mysql/schema.sql @@ -5955,6 +5955,7 @@ CREATE TABLE `#__xgroups_roles` ( `id` int(11) NOT NULL AUTO_INCREMENT, `gidNumber` int(11) DEFAULT NULL, `name` varchar(150) DEFAULT NULL, + `ordering` int(11) NOT NULL DEFAULT 0, `permissions` text DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=MyISAM DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_general_ci; 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..4af66e5e8b6 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,21 @@ 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_RESPONSES_NOT_SAVED="The canned responses could not be saved. Please try again." +PLG_GROUPS_MEMBERS_DENY_RESPONSES_TOO_LONG="These responses are too long to store. Please shorten or remove some of them and try again." +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 +183,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..5b62944c497 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,159 @@ 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 + * + * @param array $responses Responses to show instead of the saved ones, + * so a failed save can hand back what was typed + * @return void + */ + private function denyresponses($responses = null) + { + 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 = is_array($responses) ? $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); + + $serialized = $params->toString(); + + // `#__xgroups`.`params` is a TEXT column. Refusing an oversized write + // here beats letting MySQL truncate it, which would leave the group + // with unparsable JSON and so lose every other group setting. + if (strlen($serialized) > 65535) + { + $this->setError(Lang::txt('PLG_GROUPS_MEMBERS_DENY_RESPONSES_TOO_LONG')); + return $this->denyresponses($responses); + } + + $this->group->set('params', $serialized); + + // A failed write must not report success - the responses would be gone + if (!$this->group->update()) + { + $this->setError(Lang::txt('PLG_GROUPS_MEMBERS_DENY_RESPONSES_NOT_SAVED')); + return $this->denyresponses($responses); + } + + $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 +1582,200 @@ 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 + { + $ids = array_values(array_filter(array_map('intval', Request::getArray('roles', array(), 'post')))); + + // Nothing usable to order by. Saying so beats reporting success + // for a list the browser is still showing but nobody stored. + if (empty($ids)) + { + $response['message'] = Lang::txt('PLG_GROUPS_MEMBERS_ROLE_ORDER_ERROR'); + } + else + { + $db = App::get('db'); + $gid = (int) $this->group->get('gidNumber'); + + $ordering = 1; + foreach ($ids 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($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 +1965,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) { ?> -