feat(Settings): Add section to select preset

Signed-off-by: Louis Chemineau <louis@chmn.me>
pull/54570/head
Louis Chemineau 1 month ago committed by nextcloud-command
parent 9e9f3b9d16
commit ed02d0df05
  1. 2
      REUSE.toml
  2. 2
      apps/settings/appinfo/info.xml
  3. 2
      apps/settings/composer/composer/autoload_classmap.php
  4. 2
      apps/settings/composer/composer/autoload_static.php
  5. 1
      apps/settings/img/library_add_check.svg
  6. 38
      apps/settings/lib/Sections/Admin/Presets.php
  7. 54
      apps/settings/lib/Settings/Admin/Presets.php
  8. 160
      apps/settings/src/components/SettingsPresets/PresetVisualisation.vue
  9. 118
      apps/settings/src/components/SettingsPresets/PresetsSelectionForm.vue
  10. 31
      apps/settings/src/components/SettingsPresets/models.ts
  11. 19
      apps/settings/src/main-admin-settings-presets.ts
  12. 68
      apps/settings/src/views/SettingsPresets.vue
  13. 12
      apps/settings/templates/settings/admin/presets.php
  14. 6
      core/AppInfo/ConfigLexicon.php
  15. 1
      webpack.modules.js

File diff suppressed because one or more lines are too long

@ -29,6 +29,7 @@
<settings>
<admin>OCA\Settings\Settings\Admin\Mail</admin>
<admin>OCA\Settings\Settings\Admin\Overview</admin>
<admin>OCA\Settings\Settings\Admin\Presets</admin>
<admin>OCA\Settings\Settings\Admin\ArtificialIntelligence</admin>
<admin>OCA\Settings\Settings\Admin\Server</admin>
<admin>OCA\Settings\Settings\Admin\Sharing</admin>
@ -39,6 +40,7 @@
<admin-section>OCA\Settings\Sections\Admin\Delegation</admin-section>
<admin-section>OCA\Settings\Sections\Admin\Groupware</admin-section>
<admin-section>OCA\Settings\Sections\Admin\Overview</admin-section>
<admin-section>OCA\Settings\Sections\Admin\Presets</admin-section>
<admin-section>OCA\Settings\Sections\Admin\ArtificialIntelligence</admin-section>
<admin-section>OCA\Settings\Sections\Admin\Security</admin-section>
<admin-section>OCA\Settings\Sections\Admin\Server</admin-section>

@ -55,6 +55,7 @@ return array(
'OCA\\Settings\\Sections\\Admin\\Delegation' => $baseDir . '/../lib/Sections/Admin/Delegation.php',
'OCA\\Settings\\Sections\\Admin\\Groupware' => $baseDir . '/../lib/Sections/Admin/Groupware.php',
'OCA\\Settings\\Sections\\Admin\\Overview' => $baseDir . '/../lib/Sections/Admin/Overview.php',
'OCA\\Settings\\Sections\\Admin\\Presets' => $baseDir . '/../lib/Sections/Admin/Presets.php',
'OCA\\Settings\\Sections\\Admin\\Security' => $baseDir . '/../lib/Sections/Admin/Security.php',
'OCA\\Settings\\Sections\\Admin\\Server' => $baseDir . '/../lib/Sections/Admin/Server.php',
'OCA\\Settings\\Sections\\Admin\\Sharing' => $baseDir . '/../lib/Sections/Admin/Sharing.php',
@ -71,6 +72,7 @@ return array(
'OCA\\Settings\\Settings\\Admin\\Mail' => $baseDir . '/../lib/Settings/Admin/Mail.php',
'OCA\\Settings\\Settings\\Admin\\MailProvider' => $baseDir . '/../lib/Settings/Admin/MailProvider.php',
'OCA\\Settings\\Settings\\Admin\\Overview' => $baseDir . '/../lib/Settings/Admin/Overview.php',
'OCA\\Settings\\Settings\\Admin\\Presets' => $baseDir . '/../lib/Settings/Admin/Presets.php',
'OCA\\Settings\\Settings\\Admin\\Security' => $baseDir . '/../lib/Settings/Admin/Security.php',
'OCA\\Settings\\Settings\\Admin\\Server' => $baseDir . '/../lib/Settings/Admin/Server.php',
'OCA\\Settings\\Settings\\Admin\\Sharing' => $baseDir . '/../lib/Settings/Admin/Sharing.php',

@ -70,6 +70,7 @@ class ComposerStaticInitSettings
'OCA\\Settings\\Sections\\Admin\\Delegation' => __DIR__ . '/..' . '/../lib/Sections/Admin/Delegation.php',
'OCA\\Settings\\Sections\\Admin\\Groupware' => __DIR__ . '/..' . '/../lib/Sections/Admin/Groupware.php',
'OCA\\Settings\\Sections\\Admin\\Overview' => __DIR__ . '/..' . '/../lib/Sections/Admin/Overview.php',
'OCA\\Settings\\Sections\\Admin\\Presets' => __DIR__ . '/..' . '/../lib/Sections/Admin/Presets.php',
'OCA\\Settings\\Sections\\Admin\\Security' => __DIR__ . '/..' . '/../lib/Sections/Admin/Security.php',
'OCA\\Settings\\Sections\\Admin\\Server' => __DIR__ . '/..' . '/../lib/Sections/Admin/Server.php',
'OCA\\Settings\\Sections\\Admin\\Sharing' => __DIR__ . '/..' . '/../lib/Sections/Admin/Sharing.php',
@ -86,6 +87,7 @@ class ComposerStaticInitSettings
'OCA\\Settings\\Settings\\Admin\\Mail' => __DIR__ . '/..' . '/../lib/Settings/Admin/Mail.php',
'OCA\\Settings\\Settings\\Admin\\MailProvider' => __DIR__ . '/..' . '/../lib/Settings/Admin/MailProvider.php',
'OCA\\Settings\\Settings\\Admin\\Overview' => __DIR__ . '/..' . '/../lib/Settings/Admin/Overview.php',
'OCA\\Settings\\Settings\\Admin\\Presets' => __DIR__ . '/..' . '/../lib/Settings/Admin/Presets.php',
'OCA\\Settings\\Settings\\Admin\\Security' => __DIR__ . '/..' . '/../lib/Settings/Admin/Security.php',
'OCA\\Settings\\Settings\\Admin\\Server' => __DIR__ . '/..' . '/../lib/Settings/Admin/Server.php',
'OCA\\Settings\\Settings\\Admin\\Sharing' => __DIR__ . '/..' . '/../lib/Settings/Admin/Sharing.php',

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368\"><path d="m508-398 226-226-56-58-170 170-86-84-56 56 142 142ZM320-240q-33 0-56.5-23.5T240-320v-480q0-33 23.5-56.5T320-880h480q33 0 56.5 23.5T880-800v480q0 33-23.5 56.5T800-240H320Zm0-80h480v-480H320v480ZM160-80q-33 0-56.5-23.5T80-160v-560h80v560h560v80H160Zm160-720v480-480Z"/></svg>

After

Width:  |  Height:  |  Size: 390 B

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Settings\Sections\Admin;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\Settings\IIconSection;
class Presets implements IIconSection {
public function __construct(
private IL10N $l,
private IURLGenerator $urlGenerator,
) {
}
public function getIcon(): string {
return $this->urlGenerator->imagePath('settings', 'library_add_check.svg');
}
public function getID(): string {
return 'presets';
}
public function getName(): string {
return $this->l->t('Settings presets');
}
public function getPriority(): int {
return 0;
}
}

@ -0,0 +1,54 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Settings\Settings\Admin;
use OC\Config\PresetManager;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IConfig;
use OCP\IL10N;
use OCP\ServerVersion;
use OCP\Settings\ISettings;
class Presets implements ISettings {
public function __construct(
private ServerVersion $serverVersion,
private IConfig $config,
private IL10N $l,
private readonly PresetManager $presetManager,
private IInitialState $initialState,
) {
}
public function getForm() {
$presets = $this->presetManager->retrieveLexiconPreset();
$selectedPreset = $this->presetManager->getLexiconPreset();
$presetsApps = $this->presetManager->retrieveLexiconPresetApps();
$this->initialState->provideInitialState('settings-selected-preset', $selectedPreset->name);
$this->initialState->provideInitialState('settings-presets', $presets);
$this->initialState->provideInitialState('settings-presets-apps', $presetsApps);
return new TemplateResponse('settings', 'settings/admin/presets', [], '');
}
public function getSection() {
return 'presets';
}
public function getPriority() {
return 0;
}
public function getName(): ?string {
return $this->l->t('Settings presets');
}
public function getAuthorizedAppConfig(): array {
return [];
}
}

@ -0,0 +1,160 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { computed } from 'vue'
import { t } from '@nextcloud/l10n'
import { loadState } from '@nextcloud/initial-state'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import type { PresetAppConfig, PresetAppConfigs, PresetAppsStates, PresetIds } from './models.ts'
const applicationsStates = loadState('settings', 'settings-presets-apps', {}) as PresetAppsStates
const props = defineProps({
presets: {
type: Object as () => PresetAppConfigs,
required: true,
},
selectedPreset: {
type: String as () => PresetIds,
default: 'NONE',
},
})
const appsConfigPresets = Object.entries(props.presets)
.map(([appId, presets]) => [appId, presets.filter(configPreset => configPreset.config === 'app')])
.filter(([, presets]) => presets.length > 0) as [string, PresetAppConfig[]][]
const userConfigPresets = Object.entries(props.presets)
.map(([appId, presets]) => [appId, presets.filter(configPreset => configPreset.config === 'user')])
.filter(([, presets]) => presets.length > 0) as [string, PresetAppConfig[]][]
const hasApplicationsPreset = computed(() => applicationsStates[props.selectedPreset].enabled.length > 0 || applicationsStates[props.selectedPreset].disabled.length > 0)
</script>
<template>
<div class="presets">
<h3 class="presets__title">
{{ t('settings', 'Default config values') }}
</h3>
<div v-if="appsConfigPresets.length > 0" class="presets__config-list">
<h4 class="presets__config-list__subtitle">
{{ t('settings', 'Applications config') }}
</h4>
<template v-for="[appId, appConfigPresets] in appsConfigPresets">
<div v-for="configPreset in appConfigPresets"
:key="appId + '-' + configPreset.entry.key"
class="presets__config-list__item">
<span>
<div>{{ configPreset.entry.definition }}</div>
<code class="presets__config-list__item__key">{{ configPreset.entry.key }}</code>
</span>
<span>
<NcCheckboxRadioSwitch v-if="configPreset.entry.type === 'BOOL'"
:model-value="configPreset.defaults[selectedPreset] === '1'"
:disabled="true" />
<code v-else>{{ configPreset.defaults[selectedPreset] }}</code>
</span>
</div>
</template>
</div>
<div v-if="userConfigPresets.length > 0" class="presets__config-list">
<h4 class="presets__config-list__subtitle">
{{ t('settings', 'User config') }}
</h4>
<template v-for="[appId, userPresets] in userConfigPresets">
<div v-for="configPreset in userPresets"
:key="appId + '-' + configPreset.entry.key"
class="presets__config-list__item">
<span>
<div>{{ configPreset.entry.definition }}</div>
<code class="presets__config-list__item__key">{{ configPreset.entry.key }}</code>
</span>
<span>
<NcCheckboxRadioSwitch v-if="configPreset.entry.type === 'BOOL'"
:model-value="configPreset.defaults[selectedPreset] === '1'"
:disabled="true" />
<code v-else>{{ configPreset.defaults[selectedPreset] }}</code>
</span>
</div>
</template>
</div>
<template v-if="hasApplicationsPreset">
<h3 class="presets__title">
{{ t('settings', 'Bundled applications') }}
</h3>
<div class="presets__app-list">
<div class="presets__app-list__enabled">
<h4 class="presets__app-list__title">
{{ t('settings', 'Enabled applications') }}
</h4>
<ul>
<li v-for="applicationId in applicationsStates[selectedPreset].enabled"
:key="applicationId">
{{ applicationId }}
</li>
</ul>
</div>
<div class="presets__app-list__disabled">
<h4 class="presets__app-list__title">
{{ t('settings', 'Disabled applications') }}
</h4>
<ul>
<li v-for="applicationId in applicationsStates[selectedPreset].disabled"
:key="applicationId">
{{ applicationId }}
</li>
</ul>
</div>
</div>
</template>
</div>
</template>
<style lang="scss" scoped>
.presets {
margin-top: 16px;
&__title {
font-size: 16px;
margin-bottom: 0;
}
&__config-list {
margin-top: 8px;
width: 55%;
&__subtitle {
font-size: 14px;
}
&__item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 2px 0;
&__key {
font-size: 12px;
color: var(--color-text-maxcontrast);
}
}
}
&__app-list {
display: flex;
gap: 32px;
&__title {
font-size: 14px;
}
}
}
</style>

@ -0,0 +1,118 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import Domain from 'vue-material-design-icons/Domain.vue'
import CloudCircleOutline from 'vue-material-design-icons/CloudCircleOutline.vue'
import SchoolOutline from 'vue-material-design-icons/SchoolOutline.vue'
import Crowd from 'vue-material-design-icons/Crowd.vue'
import AccountGroupOutline from 'vue-material-design-icons/AccountGroupOutline.vue'
import AccountOutline from 'vue-material-design-icons/AccountOutline.vue'
import MinusCircleOutline from 'vue-material-design-icons/MinusCircleOutline.vue'
import { t } from '@nextcloud/l10n'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import { type PresetAppConfigs, type PresetIds } from './models.ts'
const PresetNames = {
LARGE: t('settings', 'Large organization'),
MEDIUM: t('settings', 'Big organization'),
SMALL: t('settings', 'Small organization'),
SHARED: t('settings', 'Hosting company'),
UNIVERSITY: t('settings', 'University'),
SCHOOL: t('settings', 'School'),
CLUB: t('settings', 'Club or association'),
FAMILY: t('settings', 'Family'),
PRIVATE: t('settings', 'Personal use'),
NONE: t('settings', 'Default'),
}
const PresetsIcons = {
LARGE: Domain,
MEDIUM: Domain,
SMALL: Domain,
SHARED: CloudCircleOutline,
UNIVERSITY: SchoolOutline,
SCHOOL: SchoolOutline,
CLUB: AccountGroupOutline,
FAMILY: Crowd,
PRIVATE: AccountOutline,
NONE: MinusCircleOutline,
}
defineProps({
presets: {
type: Object as () => PresetAppConfigs,
required: true,
},
value: {
type: String as () => PresetIds,
default: '',
},
})
const emit = defineEmits<{
(e: 'input', option: string): void
}>()
</script>
<template>
<form class="presets-form">
<label v-for="(presetName, presetId) in PresetNames"
:key="presetId"
class="presets-form__option">
<components :is="PresetsIcons[presetId]" :size="32" />
<NcCheckboxRadioSwitch type="radio"
:model-value="value"
:value="presetId"
name="preset"
@update:modelValue="emit('input', presetId)" />
<span class="presets-form__option__name">{{ presetName }}</span>
</label>
</form>
</template>
<style lang="scss" scoped>
.presets-form {
display: flex;
flex-wrap: wrap;
gap: 24px;
margin-top: 32px;
&__option {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
width: 250px;
min-height: 100px;
padding: 16px;
border-radius: var(--border-radius-large);
background-color: var(--color-background-dark);
font-size: 20px;
&:hover {
background-color: var(--color-background-darker);
}
&:has(input[type=radio]:checked) {
border: 2px solid var(--color-main-text);
padding: 14px;
}
&__name {
flex-basis: 250px;
margin-top: 8px;
}
&__name, .material-design-icon {
cursor: pointer;
}
}
}
</style>

@ -0,0 +1,31 @@
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
type PresetAppConfigEntry = {
key: string
type: 'ARRAY' | 'BOOL' | 'FLOAT' | 'INT' | 'MIXED' | 'STRING'
definition: string
note: string
lazy: boolean
deprecated: boolean
}
export type PresetIds = 'LARGE' | 'MEDIUM' | 'SMALL' | 'SHARED' | 'UNIVERSITY' | 'SCHOOL' | 'CLUB' | 'FAMILY' | 'PRIVATE' | 'NONE'
export type PresetAppConfig = {
config: 'app' | 'user'
entry: PresetAppConfigEntry
defaults: Record<PresetIds, string>
value?: unknown
}
export type PresetAppConfigs = Record<string, PresetAppConfig[]>
type PresetAppsState = {
enabled: string[]
disabled: string[]
}
export type PresetAppsStates = Record<PresetIds, PresetAppsState>

@ -0,0 +1,19 @@
/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import Vue from 'vue'
import SettingsPresets from './views/SettingsPresets.vue'
import { getCSPNonce } from '@nextcloud/auth'
// CSP config for webpack dynamic chunk loading
// eslint-disable-next-line camelcase
__webpack_nonce__ = getCSPNonce()
export default new Vue({
render: h => h(SettingsPresets),
el: '#settings-presets',
name: 'SettingsPresets',
})

@ -0,0 +1,68 @@
<!--
- SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { ref } from 'vue'
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import axios from '@nextcloud/axios'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import { loadState } from '@nextcloud/initial-state'
import { showError } from '@nextcloud/dialogs'
import PresetsSelectionForm from '../components/SettingsPresets/PresetsSelectionForm.vue'
import PresetVisualisation from '../components/SettingsPresets/PresetVisualisation.vue'
import type { PresetAppConfigs, PresetIds } from '../components/SettingsPresets/models'
import logger from '../logger'
const presets = loadState('settings', 'settings-presets', {}) as PresetAppConfigs
const currentPreset = ref(loadState('settings', 'settings-selected-preset', 'NONE') as PresetIds)
const selectedPreset = ref(currentPreset.value)
const savingPreset = ref(false)
async function saveSelectedPreset() {
try {
savingPreset.value = true
await axios.post(generateUrl('/settings/preset/current'), {
presetName: selectedPreset.value,
})
currentPreset.value = selectedPreset.value
} catch (error) {
showError(t('settings', 'Failed to save selected preset.'))
logger.error('Error saving selected preset:', { error })
selectedPreset.value = currentPreset.value
} finally {
savingPreset.value = false
}
}
</script>
<template>
<NcSettingsSection :name="t('settings', 'Settings presets')"
:description="t('settings', 'Select a configuration preset for easy setup.')">
<PresetsSelectionForm v-model="selectedPreset" :presets="presets" />
<PresetVisualisation :presets="presets" :selected-preset="selectedPreset" />
<NcButton class="save-button"
variant="primary"
:disabled="selectedPreset === currentPreset || savingPreset"
@click="saveSelectedPreset()">
{{ t('settings', 'Apply') }}
<template v-if="savingPreset" #icon>
<NcLoadingIcon />
</template>
</NcButton>
</NcSettingsSection>
</template>
<style lang="scss" scoped>
.save-button {
margin-top: 16px;
}
</style>

@ -0,0 +1,12 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
\OCP\Util::addScript('settings', 'vue-settings-admin-settings-presets');
?>
<div id="settings-presets">
</div>

@ -53,7 +53,7 @@ class ConfigLexicon implements ILexicon {
Preset::FAMILY, Preset::PRIVATE => true,
default => false,
},
definition: 'Allow users to set custom share link tokens',
definition: 'Allow users to customize share URL',
lazy: true,
note: 'Shares with guessable tokens may be accessed easily. Shares with custom tokens will continue to be accessible after this setting has been disabled.',
),
@ -65,7 +65,7 @@ class ConfigLexicon implements ILexicon {
Preset::SCHOOL, Preset::UNIVERSITY, Preset::SHARED, Preset::SMALL, Preset::MEDIUM, Preset::LARGE => true,
default => false,
},
definition: 'Enforce password protection when sharing document'
definition: 'Enforce password protection for shared documents'
),
new Entry(
key: self::SHARE_LINK_EXPIRE_DATE_DEFAULT,
@ -74,7 +74,7 @@ class ConfigLexicon implements ILexicon {
Preset::SHARED, Preset::SMALL, Preset::MEDIUM, Preset::LARGE => true,
default => false,
},
definition: 'Set default expiration date for shares via link or mail'
definition: 'Default expiration date for shares via link or mail'
),
new Entry(
key: self::SHARE_LINK_EXPIRE_DATE_ENFORCED,

@ -87,6 +87,7 @@ module.exports = {
'vue-settings-admin-ai': path.join(__dirname, 'apps/settings/src', 'main-admin-ai.js'),
'vue-settings-admin-delegation': path.join(__dirname, 'apps/settings/src', 'main-admin-delegation.js'),
'vue-settings-admin-security': path.join(__dirname, 'apps/settings/src', 'main-admin-security.js'),
'vue-settings-admin-settings-presets': path.join(__dirname, 'apps/settings/src', 'main-admin-settings-presets.js'),
'vue-settings-admin-sharing': path.join(__dirname, 'apps/settings/src', 'admin-settings-sharing.ts'),
'vue-settings-apps-users-management': path.join(__dirname, 'apps/settings/src', 'main-apps-users-management.ts'),
'vue-settings-nextcloud-pdf': path.join(__dirname, 'apps/settings/src', 'main-nextcloud-pdf.js'),

Loading…
Cancel
Save