Merge pull request #44465 from nextcloud/feat/allow-to-sort-groups-abc

feat(settings): Allow to sort groups in the account management alphabetically
pull/44511/head
Ferdinand Thiessen 2 months ago committed by GitHub
commit 9afcec721f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -125,7 +125,7 @@ class UsersController extends Controller {
/* SORT OPTION: SORT_USERCOUNT or SORT_GROUPNAME */ /* SORT OPTION: SORT_USERCOUNT or SORT_GROUPNAME */
$sortGroupsBy = \OC\Group\MetaData::SORT_USERCOUNT; $sortGroupsBy = \OC\Group\MetaData::SORT_USERCOUNT;
$isLDAPUsed = false; $isLDAPUsed = false;
if ($this->config->getSystemValue('sort_groups_by_name', false)) { if ($this->config->getSystemValueBool('sort_groups_by_name', false)) {
$sortGroupsBy = \OC\Group\MetaData::SORT_GROUPNAME; $sortGroupsBy = \OC\Group\MetaData::SORT_GROUPNAME;
} else { } else {
if ($this->appManager->isEnabledForUser('user_ldap')) { if ($this->appManager->isEnabledForUser('user_ldap')) {
@ -212,13 +212,19 @@ class UsersController extends Controller {
/* LANGUAGES */ /* LANGUAGES */
$languages = $this->l10nFactory->getLanguages(); $languages = $this->l10nFactory->getLanguages();
/** Using LDAP or admins (system config) can enfore sorting by group name, in this case the frontend setting is overwritten */
$forceSortGroupByName = $sortGroupsBy === \OC\Group\MetaData::SORT_GROUPNAME;
/* FINAL DATA */ /* FINAL DATA */
$serverData = []; $serverData = [];
// groups // groups
$serverData['groups'] = array_merge_recursive($adminGroup, [$disabledUsersGroup], $groups); $serverData['groups'] = array_merge_recursive($adminGroup, [$disabledUsersGroup], $groups);
// Various data // Various data
$serverData['isAdmin'] = $isAdmin; $serverData['isAdmin'] = $isAdmin;
$serverData['sortGroups'] = $sortGroupsBy; $serverData['sortGroups'] = $forceSortGroupByName
? \OC\Group\MetaData::SORT_GROUPNAME
: (int)$this->config->getAppValue('core', 'group.sortBy', (string)\OC\Group\MetaData::SORT_USERCOUNT);
$serverData['forceSortGroupByName'] = $forceSortGroupByName;
$serverData['quotaPreset'] = $quotaPreset; $serverData['quotaPreset'] = $quotaPreset;
$serverData['allowUnlimitedQuota'] = $allowUnlimitedQuota; $serverData['allowUnlimitedQuota'] = $allowUnlimitedQuota;
$serverData['userCount'] = $userCount; $serverData['userCount'] = $userCount;
@ -247,7 +253,7 @@ class UsersController extends Controller {
* @return JSONResponse * @return JSONResponse
*/ */
public function setPreference(string $key, string $value): JSONResponse { public function setPreference(string $key, string $value): JSONResponse {
$allowed = ['newUser.sendEmail']; $allowed = ['newUser.sendEmail', 'group.sortBy'];
if (!in_array($key, $allowed, true)) { if (!in_array($key, $allowed, true)) {
return new JSONResponse([], Http::STATUS_FORBIDDEN); return new JSONResponse([], Http::STATUS_FORBIDDEN);
} }

@ -48,6 +48,32 @@
</NcCheckboxRadioSwitch> </NcCheckboxRadioSwitch>
</NcAppSettingsSection> </NcAppSettingsSection>
<NcAppSettingsSection id="groups-sorting"
:name="t('settings', 'Sorting')">
<NcNoteCard v-if="isGroupSortingEnforced" type="warning">
{{ t('settings', 'The system config enforces sorting the groups by name. This also disables showing the member count.') }}
</NcNoteCard>
<fieldset>
<legend>{{ t('settings', 'Group list sorting') }}</legend>
<NcCheckboxRadioSwitch type="radio"
:checked.sync="groupSorting"
data-test="sortGroupsByMemberCount"
:disabled="isGroupSortingEnforced"
name="group-sorting-mode"
value="member-count">
{{ t('settings', 'By member count') }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch type="radio"
:checked.sync="groupSorting"
data-test="sortGroupsByName"
:disabled="isGroupSortingEnforced"
name="group-sorting-mode"
value="name">
{{ t('settings', 'By name') }}
</NcCheckboxRadioSwitch>
</fieldset>
</NcAppSettingsSection>
<NcAppSettingsSection id="email-settings" <NcAppSettingsSection id="email-settings"
:name="t('settings', 'Send email')"> :name="t('settings', 'Send email')">
<NcCheckboxRadioSwitch type="switch" <NcCheckboxRadioSwitch type="switch"
@ -81,8 +107,10 @@ import axios from '@nextcloud/axios'
import NcAppSettingsDialog from '@nextcloud/vue/dist/Components/NcAppSettingsDialog.js' import NcAppSettingsDialog from '@nextcloud/vue/dist/Components/NcAppSettingsDialog.js'
import NcAppSettingsSection from '@nextcloud/vue/dist/Components/NcAppSettingsSection.js' import NcAppSettingsSection from '@nextcloud/vue/dist/Components/NcAppSettingsSection.js'
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js' import NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
import { GroupSorting } from '../../constants/GroupManagement.ts'
import { unlimitedQuota } from '../../utils/userUtils.ts' import { unlimitedQuota } from '../../utils/userUtils.ts'
export default { export default {
@ -92,6 +120,7 @@ export default {
NcAppSettingsDialog, NcAppSettingsDialog,
NcAppSettingsSection, NcAppSettingsSection,
NcCheckboxRadioSwitch, NcCheckboxRadioSwitch,
NcNoteCard,
NcSelect, NcSelect,
}, },
@ -110,6 +139,22 @@ export default {
}, },
computed: { computed: {
groupSorting: {
get() {
return this.$store.getters.getGroupSorting === GroupSorting.GroupName ? 'name' : 'member-count'
},
set(sorting) {
this.$store.commit('setGroupSorting', sorting === 'name' ? GroupSorting.GroupName : GroupSorting.UserCount)
},
},
/**
* Admin has configured `sort_groups_by_name` in the system config
*/
isGroupSortingEnforced() {
return this.$store.getters.getServerData.forceSortGroupByName
},
isModalOpen: { isModalOpen: {
get() { get() {
return this.open return this.open
@ -261,3 +306,9 @@ export default {
}, },
} }
</script> </script>
<style scoped lang="scss">
fieldset {
font-weight: bold;
}
</style>

@ -0,0 +1,29 @@
/**
* @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/**
* https://github.com/nextcloud/server/blob/208e38e84e1a07a49699aa90dc5b7272d24489f0/lib/private/Group/MetaData.php#L34
*/
export enum GroupSorting {
UserCount = 1,
GroupName = 2
}

@ -30,26 +30,16 @@
import { getBuilder } from '@nextcloud/browser-storage' import { getBuilder } from '@nextcloud/browser-storage'
import { getCapabilities } from '@nextcloud/capabilities' import { getCapabilities } from '@nextcloud/capabilities'
import { parseFileSize } from '@nextcloud/files' import { parseFileSize } from '@nextcloud/files'
import { generateOcsUrl } from '@nextcloud/router' import { showError } from '@nextcloud/dialogs'
import { generateOcsUrl, generateUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios' import axios from '@nextcloud/axios'
import { GroupSorting } from '../constants/GroupManagement.ts'
import api from './api.js' import api from './api.js'
import logger from '../logger.ts' import logger from '../logger.ts'
const localStorage = getBuilder('settings').persist(true).build() const localStorage = getBuilder('settings').persist(true).build()
const orderGroups = function(groups, orderBy) {
/* const SORT_USERCOUNT = 1;
* const SORT_GROUPNAME = 2;
* https://github.com/nextcloud/server/blob/208e38e84e1a07a49699aa90dc5b7272d24489f0/lib/private/Group/MetaData.php#L34
*/
if (orderBy === 1) {
return groups.sort((a, b) => a.usercount - a.disabled < b.usercount - b.disabled)
} else {
return groups.sort((a, b) => a.name.localeCompare(b.name))
}
}
const defaults = { const defaults = {
group: { group: {
id: '', id: '',
@ -64,7 +54,7 @@ const defaults = {
const state = { const state = {
users: [], users: [],
groups: [], groups: [],
orderBy: 1, orderBy: GroupSorting.UserCount,
minPasswordLength: 0, minPasswordLength: 0,
usersOffset: 0, usersOffset: 0,
usersLimit: 25, usersLimit: 25,
@ -100,8 +90,6 @@ const mutations = {
state.groups = groups.map(group => Object.assign({}, defaults.group, group)) state.groups = groups.map(group => Object.assign({}, defaults.group, group))
state.orderBy = orderBy state.orderBy = orderBy
state.userCount = userCount state.userCount = userCount
state.groups = orderGroups(state.groups, state.orderBy)
}, },
addGroup(state, { gid, displayName }) { addGroup(state, { gid, displayName }) {
try { try {
@ -114,7 +102,6 @@ const mutations = {
name: displayName, name: displayName,
}) })
state.groups.unshift(group) state.groups.unshift(group)
state.groups = orderGroups(state.groups, state.orderBy)
} catch (e) { } catch (e) {
console.error('Can\'t create group', e) console.error('Can\'t create group', e)
} }
@ -125,7 +112,6 @@ const mutations = {
const updatedGroup = state.groups[groupIndex] const updatedGroup = state.groups[groupIndex]
updatedGroup.name = displayName updatedGroup.name = displayName
state.groups.splice(groupIndex, 1, updatedGroup) state.groups.splice(groupIndex, 1, updatedGroup)
state.groups = orderGroups(state.groups, state.orderBy)
} }
}, },
removeGroup(state, gid) { removeGroup(state, gid) {
@ -143,7 +129,6 @@ const mutations = {
} }
const groups = user.groups const groups = user.groups
groups.push(gid) groups.push(gid)
state.groups = orderGroups(state.groups, state.orderBy)
}, },
removeUserGroup(state, { userid, gid }) { removeUserGroup(state, { userid, gid }) {
const group = state.groups.find(groupSearch => groupSearch.id === gid) const group = state.groups.find(groupSearch => groupSearch.id === gid)
@ -154,7 +139,6 @@ const mutations = {
} }
const groups = user.groups const groups = user.groups
groups.splice(groups.indexOf(gid), 1) groups.splice(groups.indexOf(gid), 1)
state.groups = orderGroups(state.groups, state.orderBy)
}, },
addUserSubAdmin(state, { userid, gid }) { addUserSubAdmin(state, { userid, gid }) {
const groups = state.users.find(user => user.id === userid).subadmin const groups = state.users.find(user => user.id === userid).subadmin
@ -254,6 +238,23 @@ const mutations = {
localStorage.setItem(`account_settings__${key}`, JSON.stringify(value)) localStorage.setItem(`account_settings__${key}`, JSON.stringify(value))
state.showConfig[key] = value state.showConfig[key] = value
}, },
setGroupSorting(state, sorting) {
const oldValue = state.orderBy
state.orderBy = sorting
// Persist the value on the server
axios.post(
generateUrl('/settings/users/preferences/group.sortBy'),
{
value: String(sorting),
},
).catch((error) => {
state.orderBy = oldValue
showError(t('settings', 'Could not set group sorting'))
logger.error(error)
})
},
} }
const getters = { const getters = {
@ -267,6 +268,21 @@ const getters = {
// Can't be subadmin of admin or disabled // Can't be subadmin of admin or disabled
return state.groups.filter(group => group.id !== 'admin' && group.id !== 'disabled') return state.groups.filter(group => group.id !== 'admin' && group.id !== 'disabled')
}, },
getSortedGroups(state) {
const groups = [...state.groups]
if (state.orderBy === GroupSorting.UserCount) {
return groups.sort((a, b) => {
const numA = a.usercount - a.disabled
const numB = b.usercount - b.disabled
return (numA < numB) ? 1 : (numB < numA ? -1 : a.name.localeCompare(b.name))
})
} else {
return groups.sort((a, b) => a.name.localeCompare(b.name))
}
},
getGroupSorting(state) {
return state.orderBy
},
getPasswordPolicyMinLength(state) { getPasswordPolicyMinLength(state) {
return state.minPasswordLength return state.minPasswordLength
}, },

@ -149,7 +149,7 @@ const selectedGroupDecoded = computed(() => selectedGroup.value ? decodeURICompo
/** Overall user count */ /** Overall user count */
const userCount = computed(() => store.getters.getUserCount) const userCount = computed(() => store.getters.getUserCount)
/** All available groups */ /** All available groups */
const groups = computed(() => store.getters.getGroups) const groups = computed(() => store.getters.getSortedGroups)
const { adminGroup, disabledGroup, userGroups } = useFormatGroups(groups) const { adminGroup, disabledGroup, userGroups } = useFormatGroups(groups)
/** True if the current user is an administrator */ /** True if the current user is an administrator */

@ -1353,6 +1353,7 @@ $CONFIG = [
* Sort groups in the user settings by name instead of the user count * Sort groups in the user settings by name instead of the user count
* *
* By enabling this the user count beside the group name is disabled as well. * By enabling this the user count beside the group name is disabled as well.
* @deprecated since Nextcloud 29 - Use the frontend instead or set the app config value `group.sortBy` for `core` to `2`
*/ */
'sort_groups_by_name' => false, 'sort_groups_by_name' => false,

@ -21,7 +21,7 @@
*/ */
import { User } from '@nextcloud/cypress' import { User } from '@nextcloud/cypress'
import { getUserListRow, handlePasswordConfirmation, toggleEditButton } from './usersUtils' import { assertNotExistOrNotVisible, getUserListRow, handlePasswordConfirmation, toggleEditButton } from './usersUtils'
// eslint-disable-next-line n/no-extraneous-import // eslint-disable-next-line n/no-extraneous-import
import randomString from 'crypto-random-string' import randomString from 'crypto-random-string'
@ -223,3 +223,82 @@ describe('Settings: Delete a non empty group', () => {
}) })
}) })
}) })
describe.only('Settings: Sort groups in the UI', () => {
before(() => {
// Clear state
cy.runOccCommand('group:list --output json').then((output) => {
const groups = Object.keys(JSON.parse(output.stdout)).filter((group) => group !== 'admin')
groups.forEach((group) => {
cy.runOccCommand(`group:delete "${group}"`)
})
})
// Add two groups and add one user to group B
cy.runOccCommand('group:add A')
cy.runOccCommand('group:add B')
cy.createRandomUser().then((user) => {
cy.runOccCommand(`group:adduser B "${user.userId}"`)
})
// Visit the settings as admin
cy.login(admin)
cy.visit('/settings/users')
})
it('Can set sort by member count', () => {
// open the settings dialog
cy.contains('button', 'Account management settings').click()
cy.contains('.modal-container', 'Account management settings').within(() => {
cy.get('[data-test="sortGroupsByMemberCount"] input[type="radio"]').scrollIntoView()
cy.get('[data-test="sortGroupsByMemberCount"] input[type="radio"]').check({ force: true })
// close the settings dialog
cy.get('button.modal-container__close').click()
})
cy.waitUntil(() => cy.get('.modal-container').should(el => assertNotExistOrNotVisible(el)))
})
it('See that the groups are sorted by the member count', () => {
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => {
cy.get('li').eq(0).should('contain', 'B') // 1 member
cy.get('li').eq(1).should('contain', 'A') // 0 members
})
})
it('See that the order is preserved after a reload', () => {
cy.reload()
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => {
cy.get('li').eq(0).should('contain', 'B') // 1 member
cy.get('li').eq(1).should('contain', 'A') // 0 members
})
})
it('Can set sort by group name', () => {
// open the settings dialog
cy.contains('button', 'Account management settings').click()
cy.contains('.modal-container', 'Account management settings').within(() => {
cy.get('[data-test="sortGroupsByName"] input[type="radio"]').scrollIntoView()
cy.get('[data-test="sortGroupsByName"] input[type="radio"]').check({ force: true })
// close the settings dialog
cy.get('button.modal-container__close').click()
})
cy.waitUntil(() => cy.get('.modal-container').should(el => assertNotExistOrNotVisible(el)))
})
it('See that the groups are sorted by the user count', () => {
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => {
cy.get('li').eq(0).should('contain', 'A')
cy.get('li').eq(1).should('contain', 'B')
})
})
it('See that the order is preserved after a reload', () => {
cy.reload()
cy.get('ul[data-cy-users-settings-navigation-groups="custom"]').within(() => {
cy.get('li').eq(0).should('contain', 'A')
cy.get('li').eq(1).should('contain', 'B')
})
})
})

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -266,3 +266,25 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>. * along with this program. If not, see <http://www.gnu.org/licenses/>.
* *
*/ */
/**
* @copyright Copyright (c) 2024 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save