Browse Source

feat(settings): Allow to sort groups in the account management alphabetically

We can do this purly in the frontend - but when enforced from the backend using the existing system config,
we need to follow the requirement. We then show a warning about the configuration.

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
tags/v29.0.0rc1
Ferdinand Thiessen 1 month ago
parent
commit
5453c1d7fa
No account linked to committer's email address

+ 9
- 3
apps/settings/lib/Controller/UsersController.php View File

/* 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')) {
/* 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;
* @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);
} }

+ 51
- 0
apps/settings/src/components/Users/UserSettingsDialog.vue View File

</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"
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 {
NcAppSettingsDialog, NcAppSettingsDialog,
NcAppSettingsSection, NcAppSettingsSection,
NcCheckboxRadioSwitch, NcCheckboxRadioSwitch,
NcNoteCard,
NcSelect, NcSelect,
}, },


}, },


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
}, },
} }
</script> </script>

<style scoped lang="scss">
fieldset {
font-weight: bold;
}
</style>

+ 29
- 0
apps/settings/src/constants/GroupManagement.ts View File

/**
* @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
}

+ 36
- 20
apps/settings/src/store/users.js View File

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: '',
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,
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 {
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)
} }
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) {
} }
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)
} }
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
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 = {
// 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
}, },

+ 1
- 1
apps/settings/src/views/UserManagementNavigation.vue View File

/** 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 */

+ 1
- 0
config/config.sample.php View File

* 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,



+ 80
- 1
cypress/e2e/settings/users_groups.cy.ts View File

*/ */


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'
}) })
}) })
}) })

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')
})
})
})

Loading…
Cancel
Save