diff --git a/changelog/unreleased/enhancement-add-notifications-settings b/changelog/unreleased/enhancement-add-notifications-settings
new file mode 100644
index 00000000000..9903640a8ad
--- /dev/null
+++ b/changelog/unreleased/enhancement-add-notifications-settings
@@ -0,0 +1,6 @@
+Enhancement: Add notifications settings
+
+We've added a new notifications settings section into the account screen. This section allows users to configure what notifications they wish to receive either in-app or via email, when to receive email notifications, and drops the previous notifications toggle.
+
+https://github.com/owncloud/web/pull/12010
+https://github.com/owncloud/web/issues/9248
diff --git a/packages/web-client/src/ocs/capabilities.ts b/packages/web-client/src/ocs/capabilities.ts
index 3719dd12a8a..57408cbef90 100644
--- a/packages/web-client/src/ocs/capabilities.ts
+++ b/packages/web-client/src/ocs/capabilities.ts
@@ -72,6 +72,7 @@ export interface Capabilities {
}
notifications?: {
'ocs-endpoints'?: string[]
+ configurable?: boolean
}
core: {
pollinterval?: number
diff --git a/packages/web-runtime/src/components/Account/AccountTable.vue b/packages/web-runtime/src/components/Account/AccountTable.vue
index 27c1dfeb16a..7f24f96c730 100644
--- a/packages/web-runtime/src/components/Account/AccountTable.vue
+++ b/packages/web-runtime/src/components/Account/AccountTable.vue
@@ -4,9 +4,17 @@
-
+
- {{ field }}
+
+ {{ field }}
+ {{ field.label }}
+
@@ -19,6 +27,12 @@
@@ -85,11 +100,13 @@ export default defineComponent({
}
}
- td:nth-child(3) {
- display: flex;
- justify-content: end;
- align-items: center;
- min-height: var(--oc-size-height-table-row);
+ @media (min-width: 801px) {
+ td > .checkbox-cell-wrapper {
+ display: flex;
+ justify-content: end;
+ align-items: center;
+ min-height: var(--oc-size-height-table-row);
+ }
}
}
diff --git a/packages/web-runtime/src/composables/notificationsSettings/index.ts b/packages/web-runtime/src/composables/notificationsSettings/index.ts
new file mode 100644
index 00000000000..5c88d17cb8f
--- /dev/null
+++ b/packages/web-runtime/src/composables/notificationsSettings/index.ts
@@ -0,0 +1 @@
+export * from './useNotificationsSettings'
diff --git a/packages/web-runtime/src/composables/notificationsSettings/useNotificationsSettings.ts b/packages/web-runtime/src/composables/notificationsSettings/useNotificationsSettings.ts
new file mode 100644
index 00000000000..7f0a46ccdcf
--- /dev/null
+++ b/packages/web-runtime/src/composables/notificationsSettings/useNotificationsSettings.ts
@@ -0,0 +1,65 @@
+import { computed, Ref, unref } from 'vue'
+import {
+ getSettingsValue,
+ SETTINGS_EMAIL_NOTIFICATION_BUNDLE_IDS,
+ SETTINGS_NOTIFICATION_BUNDLE_IDS,
+ SettingsBundle,
+ SettingsValue
+} from '../../helpers/settings'
+
+export const useNotificationsSettings = (
+ valueList: Ref,
+ bundle: Ref
+) => {
+ const values = computed(() => {
+ if (!unref(bundle)) {
+ return {}
+ }
+
+ return unref(bundle).settings.reduce((acc, curr) => {
+ if (!SETTINGS_NOTIFICATION_BUNDLE_IDS.includes(curr.id)) {
+ return acc
+ }
+
+ acc[curr.id] = getSettingsValue(curr, unref(valueList))
+
+ return acc
+ }, {})
+ })
+
+ const options = computed(() => {
+ if (!unref(bundle)) {
+ return []
+ }
+
+ return unref(bundle).settings.filter(({ id }) => SETTINGS_NOTIFICATION_BUNDLE_IDS.includes(id))
+ })
+
+ const emailOptions = computed(() => {
+ if (!unref(bundle)) {
+ return []
+ }
+
+ return unref(bundle).settings.filter(({ id }) =>
+ SETTINGS_EMAIL_NOTIFICATION_BUNDLE_IDS.includes(id)
+ )
+ })
+
+ const emailValues = computed(() => {
+ if (!unref(bundle)) {
+ return {}
+ }
+
+ return unref(bundle).settings.reduce((acc, curr) => {
+ if (!SETTINGS_EMAIL_NOTIFICATION_BUNDLE_IDS.includes(curr.id)) {
+ return acc
+ }
+
+ acc[curr.id] = getSettingsValue(curr, unref(valueList))
+
+ return acc
+ }, {})
+ })
+
+ return { values, options, emailOptions, emailValues }
+}
diff --git a/packages/web-runtime/src/helpers/settings.ts b/packages/web-runtime/src/helpers/settings.ts
index db944bea92c..bd098558b19 100644
--- a/packages/web-runtime/src/helpers/settings.ts
+++ b/packages/web-runtime/src/helpers/settings.ts
@@ -1,3 +1,5 @@
+import { captureException } from '@sentry/vue'
+
export interface SettingsValue {
identifier: {
bundle: string
@@ -18,7 +20,40 @@ export interface SettingsValue {
stringValue: string
}[]
}
+ collectionValue?: {
+ values: {
+ key: string
+ boolValue: boolean
+ }[]
+ }
+ stringValue?: string
+ }
+}
+
+interface SettingsBundleSetting {
+ description: string
+ displayName: string
+ id: string
+ name: string
+ resource: {
+ type: string
}
+ singleChoiceValue?: {
+ options: Record[]
+ }
+ multiChoiceCollectionValue?: {
+ options: {
+ value: {
+ boolValue: {
+ default?: boolean
+ }
+ }
+ key: string
+ displayValue: string
+ attribute?: 'disabled'
+ }[]
+ }
+ boolValue?: Record
}
export interface SettingsBundle {
@@ -29,19 +64,7 @@ export interface SettingsBundle {
resource: {
type: string
}
- settings: {
- description: string
- displayName: string
- id: string
- name: string
- resource: {
- type: string
- }
- singleChoiceValue?: {
- options: Record[]
- }
- boolValue?: Record
- }[]
+ settings: SettingsBundleSetting[]
type: string
roleId?: string
}
@@ -50,3 +73,99 @@ export interface LanguageOption {
label: string
value: string
}
+
+/** IDs of notifications setting bundles */
+export enum SettingsNotificationBundle {
+ ShareCreated = '872d8ef6-6f2a-42ab-af7d-f53cc81d7046',
+ ShareRemoved = 'd7484394-8321-4c84-9677-741ba71e1f80',
+ ShareExpired = 'e1aa0b7c-1b0f-4072-9325-c643c89fee4e',
+ SpaceShared = '694d5ee1-a41c-448c-8d14-396b95d2a918',
+ SpaceUnshared = '26c20e0e-98df-4483-8a77-759b3a766af0',
+ SpaceMembershipExpired = '7275921e-b737-4074-ba91-3c2983be3edd',
+ SpaceDisabled = 'eb5c716e-03be-42c6-9ed1-1105d24e109f',
+ SpaceDeleted = '094ceca9-5a00-40ba-bb1a-bbc7bccd39ee',
+ PostprocessingStepFinished = 'fe0a3011-d886-49c8-b797-33d02fa426ef',
+ ScienceMeshInviteTokenGenerated = 'b441ffb1-f5ee-4733-a08f-48d03f6e7f22'
+}
+
+/** IDs of email notifications setting bundles */
+export enum SettingsEmailNotificationBundle {
+ EmailSendingInterval = '08dec2fe-3f97-42a9-9d1b-500855e92f25'
+}
+
+// We need the type specified here because e.g. includes method would otherwise complain about it
+export const SETTINGS_NOTIFICATION_BUNDLE_IDS: string[] = [
+ SettingsNotificationBundle.ShareCreated,
+ SettingsNotificationBundle.ShareRemoved,
+ SettingsNotificationBundle.ShareExpired,
+ SettingsNotificationBundle.SpaceShared,
+ SettingsNotificationBundle.SpaceUnshared,
+ SettingsNotificationBundle.SpaceMembershipExpired,
+ SettingsNotificationBundle.SpaceDisabled,
+ SettingsNotificationBundle.SpaceDeleted,
+ SettingsNotificationBundle.PostprocessingStepFinished,
+ SettingsNotificationBundle.ScienceMeshInviteTokenGenerated
+]
+
+export const SETTINGS_EMAIL_NOTIFICATION_BUNDLE_IDS: string[] = [
+ SettingsEmailNotificationBundle.EmailSendingInterval
+]
+
+function getSettingsDefaultValue(setting: SettingsBundleSetting) {
+ if (setting.singleChoiceValue) {
+ const [option] = setting.singleChoiceValue.options
+
+ return {
+ value: option.value.stringValue,
+ displayValue: option.displayValue
+ }
+ }
+
+ if (setting.multiChoiceCollectionValue) {
+ return setting.multiChoiceCollectionValue.options.reduce((acc, curr) => {
+ acc[curr.key] = curr.value.boolValue.default
+
+ return acc
+ }, {})
+ }
+
+ const error = new Error('Unsupported setting value')
+
+ console.error(error)
+ captureException(error)
+
+ return null
+}
+
+export function getSettingsValue(
+ setting: SettingsBundleSetting,
+ valueList: SettingsValue[]
+): boolean | string | null | { [key: string]: boolean } | { value: string; displayValue: string } {
+ const { value } = valueList.find((v) => v.identifier.setting === setting.name) || {}
+
+ if (!value) {
+ return getSettingsDefaultValue(setting)
+ }
+
+ if (value.collectionValue) {
+ return setting.multiChoiceCollectionValue.options.reduce((acc, curr) => {
+ const val = value.collectionValue.values.find((v) => v.key === curr.key)
+
+ if (val) {
+ acc[curr.key] = val.boolValue
+ return acc
+ }
+
+ acc[curr.key] = curr.value.boolValue.default
+ return acc
+ }, {})
+ }
+
+ if (value.stringValue) {
+ const option = setting.singleChoiceValue.options.find(
+ (o) => o.value.stringValue === value.stringValue
+ )
+
+ return { value: value.stringValue, displayValue: option?.displayValue || value.stringValue }
+ }
+}
diff --git a/packages/web-runtime/src/pages/account.vue b/packages/web-runtime/src/pages/account.vue
index 2ccb54c45b6..0d725178d96 100644
--- a/packages/web-runtime/src/pages/account.vue
+++ b/packages/web-runtime/src/pages/account.vue
@@ -128,7 +128,10 @@
-
+
{{ $gettext('Notifications') }}
@@ -161,6 +164,73 @@
+
+
+
+
+ {{ title }}
+
+ {{
+ $gettext(
+ 'Personalise your notification preferences about any file, folder, or Space.'
+ )
+ }}
+
+
+
+
+ {{ option.displayName }}
+ {{ option.description }}
+
+
+
+
+ updateMultiChoiceSettingsValue(option.name, choice.key, value)
+ "
+ />
+
+
+
+
+
+
+
+ {{ title }}
+
+
+
+ {{ option.displayName }}
+ {{ option.description }}
+
+
+ updateSingleChoiceValue(option.name, value)"
+ />
+
+
+
+
+
(window.innerWidth < MOBILE_BREAKPOINT)
const onResize = () => {
@@ -402,9 +480,8 @@ export default defineComponent({
}: {
identifier: string
valueOptions: Record
- }) => {
- const valueId = unref(valuesList)?.find((cV) => cV.identifier.setting === identifier)?.value
- ?.id
+ }): Promise => {
+ let valueId = unref(valuesList)?.find((cV) => cV.identifier.setting === identifier)?.value?.id
const value = {
bundleId: unref(accountBundle)?.id,
@@ -416,12 +493,22 @@ export default defineComponent({
}
try {
- await clientService.httpAuthenticated.post('/api/v0/settings/values-save', {
- value: {
- accountUuid: 'me',
- ...value
+ const {
+ data: { value: data }
+ } = await clientService.httpAuthenticated.post<{ value: SettingsValue }>(
+ '/api/v0/settings/values-save',
+ {
+ value: {
+ accountUuid: 'me',
+ ...value
+ }
}
- })
+ )
+
+ // Not sure if we can remove the condition below so just assign this here to be 100% safe
+ if (data.value.id) {
+ valueId = data.value.id
+ }
/**
* Edge case: we need to reload the values list to retrieve the valueId if not set,
@@ -431,7 +518,7 @@ export default defineComponent({
loadValuesListTask.perform()
}
- return value
+ return data
} catch (e) {
throw e
}
@@ -525,6 +612,100 @@ export default defineComponent({
})
})
+ const notificationsSettingsFields = computed(() => [
+ { label: $gettext('Event') },
+ { label: $gettext('Event description'), hidden: true },
+ { label: $gettext('In-App'), alignH: 'right' },
+ { label: $gettext('Mail'), alignH: 'right' }
+ ])
+
+ const emailNotificationsOptionsFields = computed(() => [
+ { label: $gettext('Options') },
+ { label: $gettext('Option description'), hidden: true },
+ { label: $gettext('Option value'), hidden: true }
+ ])
+
+ const updateValueInValueList = (value: SettingsValue) => {
+ const index = unref(valuesList).findIndex(
+ (v) => v.identifier.setting === value.identifier.setting
+ )
+
+ if (index < 0) {
+ valuesList.value.push(value)
+ return
+ }
+
+ valuesList.value.splice(index, 1, value)
+ }
+
+ const updateMultiChoiceSettingsValue = async (
+ identifier: string,
+ key: string,
+ value: boolean | string
+ ) => {
+ try {
+ if (typeof value !== 'boolean') {
+ const error = new TypeError(`Unsupported value type ${typeof value}`)
+
+ console.error(error)
+ captureException(error)
+
+ return
+ }
+
+ const currentValue = unref(valuesList).find((v) => v.identifier.setting === identifier)
+
+ const savedValue = await saveValue({
+ identifier,
+ valueOptions: {
+ collectionValue: {
+ values: [
+ ...(currentValue?.value.collectionValue.values.filter((val) => val.key !== key) ||
+ []),
+ { key, boolValue: value }
+ ]
+ }
+ }
+ })
+
+ updateValueInValueList(savedValue)
+ showMessage({ title: $gettext('Preference saved.') })
+ } catch (error) {
+ captureException(error)
+ console.error(error)
+ showErrorMessage({
+ title: $gettext('Unable to save preference…'),
+ errors: [error]
+ })
+ }
+ }
+
+ const updateSingleChoiceValue = async (
+ identifier: string,
+ value: { displayValue: string; value: { stringValue: string } }
+ ): Promise => {
+ try {
+ const savedValue = await saveValue({
+ identifier,
+ valueOptions: { stringValue: value.value.stringValue }
+ })
+
+ updateValueInValueList(savedValue)
+ showMessage({ title: $gettext('Preference saved.') })
+ } catch (error) {
+ captureException(error)
+ console.error(error)
+ showErrorMessage({
+ title: $gettext('Unable to save preference…'),
+ errors: [error]
+ })
+ }
+ }
+
+ const canConfigureSpecificNotifications = computed(
+ () => capabilityStore.capabilities.notifications.configurable
+ )
+
onMounted(async () => {
window.addEventListener('resize', onResize)
@@ -583,7 +764,16 @@ export default defineComponent({
loadValuesListTask,
showEditPasswordModal,
quota,
- isMobileWidth
+ isMobileWidth,
+ notificationsOptions,
+ notificationsSettingsFields,
+ emailNotificationsOptionsFields,
+ emailNotificationsOptions,
+ notificationsValues,
+ updateMultiChoiceSettingsValue,
+ emailNotificationsValues,
+ updateSingleChoiceValue,
+ canConfigureSpecificNotifications
}
}
})
diff --git a/packages/web-runtime/tests/unit/pages/__snapshots__/account.spec.ts.snap b/packages/web-runtime/tests/unit/pages/__snapshots__/account.spec.ts.snap
index 522484b53ec..ff0758abacb 100644
--- a/packages/web-runtime/tests/unit/pages/__snapshots__/account.spec.ts.snap
+++ b/packages/web-runtime/tests/unit/pages/__snapshots__/account.spec.ts.snap
@@ -134,6 +134,7 @@ exports[`account page > public link context > should render a limited view 1`] =
+
"
`;
diff --git a/packages/web-runtime/tests/unit/pages/account.spec.ts b/packages/web-runtime/tests/unit/pages/account.spec.ts
index 7cbd9f56b80..9c1f473fe7d 100644
--- a/packages/web-runtime/tests/unit/pages/account.spec.ts
+++ b/packages/web-runtime/tests/unit/pages/account.spec.ts
@@ -2,9 +2,9 @@ import account from '../../../src/pages/account.vue'
import {
defaultComponentMocks,
defaultPlugins,
+ mockAxiosReject,
mockAxiosResolve,
- mount,
- mockAxiosReject
+ mount
} from '@ownclouders/web-test-helpers'
import { mock } from 'vitest-mock-extended'
import {
@@ -160,7 +160,9 @@ describe('account page', () => {
const { wrapper, mocks } = getWrapper()
await blockLoadingState(wrapper)
- mocks.$clientService.httpAuthenticated.post.mockResolvedValueOnce(mockAxiosResolve({}))
+ mocks.$clientService.httpAuthenticated.post.mockResolvedValueOnce(
+ mockAxiosResolve({ value: { id: 'settings-language' } })
+ )
await wrapper.vm.updateDisableEmailNotifications(true)
const { showMessage } = useMessages()
expect(showMessage).toHaveBeenCalled()
@@ -269,6 +271,68 @@ describe('account page', () => {
expect(wrapper.find(selectors.extensionsSection).exists()).toBeTruthy()
})
})
+
+ describe('Method "updateMultiChoiceSettingsValue"', () => {
+ it('should show a message on success', async () => {
+ const { wrapper, mocks } = getWrapper({})
+ await blockLoadingState(wrapper)
+
+ mocks.$clientService.httpAuthenticated.post.mockResolvedValueOnce(
+ mockAxiosResolve({
+ value: { identifier: { setting: 'setting-id' }, value: { id: 'value-id' } }
+ })
+ )
+ await wrapper.vm.updateMultiChoiceSettingsValue('setting-id', 'setting-key', true)
+ const { showMessage } = useMessages()
+ expect(showMessage).toHaveBeenCalled()
+ })
+
+ it('should show a message on error', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => undefined)
+
+ const { wrapper, mocks } = getWrapper({})
+ await blockLoadingState(wrapper)
+
+ mocks.$clientService.httpAuthenticated.post.mockImplementation(() => mockAxiosReject('err'))
+ await wrapper.vm.updateMultiChoiceSettingsValue('setting-id', 'setting-key', true)
+ const { showErrorMessage } = useMessages()
+ expect(showErrorMessage).toHaveBeenCalled()
+ })
+ })
+
+ describe('Method "updateSingleChoiceValue"', () => {
+ it('should show a message on success', async () => {
+ const { wrapper, mocks } = getWrapper({})
+ await blockLoadingState(wrapper)
+
+ mocks.$clientService.httpAuthenticated.post.mockResolvedValueOnce(
+ mockAxiosResolve({
+ value: { identifier: { setting: 'setting-id' }, value: { id: 'value-id' } }
+ })
+ )
+ await wrapper.vm.updateSingleChoiceValue('setting-id', {
+ displayValue: 'Daily',
+ value: { stringValue: 'daily' }
+ })
+ const { showMessage } = useMessages()
+ expect(showMessage).toHaveBeenCalled()
+ })
+
+ it('should show a message on error', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => undefined)
+
+ const { wrapper, mocks } = getWrapper({})
+ await blockLoadingState(wrapper)
+
+ mocks.$clientService.httpAuthenticated.post.mockImplementation(() => mockAxiosReject('err'))
+ await wrapper.vm.updateSingleChoiceValue('setting-id', {
+ displayValue: 'Daily',
+ value: { stringValue: 'daily' }
+ })
+ const { showErrorMessage } = useMessages()
+ expect(showErrorMessage).toHaveBeenCalled()
+ })
+ })
})
const blockLoadingState = async (wrapper: VueWrapper) => {