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