diff options
Diffstat (limited to 'apps/dav/src')
20 files changed, 1442 insertions, 544 deletions
diff --git a/apps/dav/src/components/AbsenceForm.vue b/apps/dav/src/components/AbsenceForm.vue new file mode 100644 index 00000000000..5350c04a565 --- /dev/null +++ b/apps/dav/src/components/AbsenceForm.vue @@ -0,0 +1,274 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <form class="absence" @submit.prevent="saveForm"> + <div class="absence__dates"> + <NcDateTimePickerNative id="absence-first-day" + v-model="firstDay" + :label="$t('dav', 'First day')" + class="absence__dates__picker" + :required="true" /> + <NcDateTimePickerNative id="absence-last-day" + v-model="lastDay" + :label="$t('dav', 'Last day (inclusive)')" + class="absence__dates__picker" + :required="true" /> + </div> + <label for="replacement-search-input">{{ $t('dav', 'Out of office replacement (optional)') }}</label> + <NcSelect ref="select" + v-model="replacementUser" + input-id="replacement-search-input" + :loading="searchLoading" + :placeholder="$t('dav', 'Name of the replacement')" + :clear-search-on-blur="() => false" + :user-select="true" + :options="options" + @search="asyncFind"> + <template #no-options="{ search }"> + {{ search ?$t('dav', 'No results.') : $t('dav', 'Start typing.') }} + </template> + </NcSelect> + <NcTextField :value.sync="status" :label="$t('dav', 'Short absence status')" :required="true" /> + <NcTextArea :value.sync="message" :label="$t('dav', 'Long absence Message')" :required="true" /> + + <div class="absence__buttons"> + <NcButton :disabled="loading || !valid" + type="primary" + native-type="submit"> + {{ $t('dav', 'Save') }} + </NcButton> + <NcButton :disabled="loading || !valid" + type="error" + @click="clearAbsence"> + {{ $t('dav', 'Disable absence') }} + </NcButton> + </div> + </form> +</template> + +<script> +import { getCurrentUser } from '@nextcloud/auth' +import { showError, showSuccess } from '@nextcloud/dialogs' +import { loadState } from '@nextcloud/initial-state' +import { generateOcsUrl } from '@nextcloud/router' +import { ShareType } from '@nextcloud/sharing' +import { formatDateAsYMD } from '../utils/date.js' +import axios from '@nextcloud/axios' +import debounce from 'debounce' +import logger from '../service/logger.js' + +import NcButton from '@nextcloud/vue/components/NcButton' +import NcTextField from '@nextcloud/vue/components/NcTextField' +import NcTextArea from '@nextcloud/vue/components/NcTextArea' +import NcSelect from '@nextcloud/vue/components/NcSelect' +import NcDateTimePickerNative from '@nextcloud/vue/components/NcDateTimePickerNative' + +export default { + name: 'AbsenceForm', + components: { + NcButton, + NcTextField, + NcTextArea, + NcDateTimePickerNative, + NcSelect, + }, + data() { + const { firstDay, lastDay, status, message, replacementUserId, replacementUserDisplayName } = loadState('dav', 'absence', {}) + return { + loading: false, + status: status ?? '', + message: message ?? '', + firstDay: firstDay ? new Date(firstDay) : new Date(), + lastDay: lastDay ? new Date(lastDay) : null, + replacementUserId, + replacementUser: replacementUserId ? { user: replacementUserId, displayName: replacementUserDisplayName } : null, + searchLoading: false, + options: [], + } + }, + computed: { + /** + * @return {boolean} + */ + valid() { + // Translate the two date objects to midnight for an accurate comparison + const firstDay = new Date(this.firstDay?.getTime()) + const lastDay = new Date(this.lastDay?.getTime()) + firstDay?.setHours(0, 0, 0, 0) + lastDay?.setHours(0, 0, 0, 0) + + return !!this.firstDay + && !!this.lastDay + && !!this.status + && !!this.message + && lastDay >= firstDay + }, + }, + methods: { + resetForm() { + this.status = '' + this.message = '' + this.firstDay = new Date() + this.lastDay = null + }, + + /** + * Format shares for the multiselect options + * + * @param {object} result select entry item + * @return {object} + */ + formatForMultiselect(result) { + return { + user: result.uuid || result.value.shareWith, + displayName: result.name || result.label, + subtitle: result.dsc | '', + } + }, + + async asyncFind(query) { + this.searchLoading = true + await this.debounceGetSuggestions(query.trim()) + }, + /** + * Get suggestions + * + * @param {string} search the search query + */ + async getSuggestions(search) { + + const shareType = [ + ShareType.User, + ] + + let request = null + try { + request = await axios.get(generateOcsUrl('apps/files_sharing/api/v1/sharees'), { + params: { + format: 'json', + itemType: 'file', + search, + shareType, + }, + }) + } catch (error) { + console.error('Error fetching suggestions', error) + return + } + + const data = request.data.ocs.data + const exact = request.data.ocs.data.exact + data.exact = [] // removing exact from general results + const rawExactSuggestions = exact.users + const rawSuggestions = data.users + console.info('rawExactSuggestions', rawExactSuggestions) + console.info('rawSuggestions', rawSuggestions) + // remove invalid data and format to user-select layout + const exactSuggestions = rawExactSuggestions + .map(share => this.formatForMultiselect(share)) + const suggestions = rawSuggestions + .map(share => this.formatForMultiselect(share)) + + const allSuggestions = exactSuggestions.concat(suggestions) + + // Count occurrences of display names in order to provide a distinguishable description if needed + const nameCounts = allSuggestions.reduce((nameCounts, result) => { + if (!result.displayName) { + return nameCounts + } + if (!nameCounts[result.displayName]) { + nameCounts[result.displayName] = 0 + } + nameCounts[result.displayName]++ + return nameCounts + }, {}) + + this.options = allSuggestions.map(item => { + // Make sure that items with duplicate displayName get the shareWith applied as a description + if (nameCounts[item.displayName] > 1 && !item.desc) { + return { ...item, desc: item.shareWithDisplayNameUnique } + } + return item + }) + + this.searchLoading = false + console.info('suggestions', this.options) + }, + + /** + * Debounce getSuggestions + * + * @param {...*} args the arguments + */ + debounceGetSuggestions: debounce(function(...args) { + this.getSuggestions(...args) + }, 300), + + async saveForm() { + if (!this.valid) { + return + } + + this.loading = true + try { + await axios.post(generateOcsUrl('/apps/dav/api/v1/outOfOffice/{userId}', { userId: getCurrentUser().uid }), { + firstDay: formatDateAsYMD(this.firstDay), + lastDay: formatDateAsYMD(this.lastDay), + status: this.status, + message: this.message, + replacementUserId: this.replacementUser?.user ?? null, + }) + showSuccess(this.$t('dav', 'Absence saved')) + } catch (error) { + showError(this.$t('dav', 'Failed to save your absence settings')) + logger.error('Could not save absence', { error }) + } finally { + this.loading = false + } + }, + async clearAbsence() { + this.loading = true + try { + await axios.delete(generateOcsUrl('/apps/dav/api/v1/outOfOffice/{userId}', { userId: getCurrentUser().uid })) + this.resetForm() + showSuccess(this.$t('dav', 'Absence cleared')) + } catch (error) { + showError(this.$t('dav', 'Failed to clear your absence settings')) + logger.error('Could not clear absence', { error }) + } finally { + this.loading = false + } + }, + }, +} +</script> + +<style lang="scss" scoped> +.absence { + display: flex; + flex-direction: column; + gap: 5px; + + &__dates { + display: flex; + gap: 10px; + width: 100%; + + &__picker { + flex: 1 auto; + + :deep(.native-datetime-picker--input) { + margin-bottom: 0; + } + } + } + + &__buttons { + display: flex; + gap: 5px; + } +} +</style> diff --git a/apps/dav/src/components/AvailabilityForm.vue b/apps/dav/src/components/AvailabilityForm.vue new file mode 100644 index 00000000000..d53c092be9d --- /dev/null +++ b/apps/dav/src/components/AvailabilityForm.vue @@ -0,0 +1,223 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <div> + <div class="time-zone"> + <label :for="`vs${timeZonePickerId}__combobox`" class="time-zone__heading"> + {{ $t('dav', 'Time zone:') }} + </label> + <span class="time-zone-text"> + <NcTimezonePicker v-model="timezone" :uid="timeZonePickerId" /> + </span> + </div> + + <CalendarAvailability :slots.sync="slots" + :loading="loading" + :l10n-to="$t('dav', 'to')" + :l10n-delete-slot="$t('dav', 'Delete slot')" + :l10n-empty-day="$t('dav', 'No working hours set')" + :l10n-add-slot="$t('dav', 'Add slot')" + :l10n-week-day-list-label="$t('dav', 'Weekdays')" + :l10n-monday="$t('dav', 'Monday')" + :l10n-tuesday="$t('dav', 'Tuesday')" + :l10n-wednesday="$t('dav', 'Wednesday')" + :l10n-thursday="$t('dav', 'Thursday')" + :l10n-friday="$t('dav', 'Friday')" + :l10n-saturday="$t('dav', 'Saturday')" + :l10n-sunday="$t('dav', 'Sunday')" + :l10n-start-picker-label="(dayName) => $t('dav', 'Pick a start time for {dayName}', { dayName })" + :l10n-end-picker-label="(dayName) => $t('dav', 'Pick a end time for {dayName}', { dayName })" /> + + <NcCheckboxRadioSwitch :checked.sync="automated"> + {{ $t('dav', 'Automatically set user status to "Do not disturb" outside of availability to mute all notifications.') }} + </NcCheckboxRadioSwitch> + + <NcButton :disabled="loading || saving" + type="primary" + @click="save"> + {{ $t('dav', 'Save') }} + </NcButton> + </div> +</template> + +<script> +import { CalendarAvailability } from '@nextcloud/calendar-availability-vue' +import { loadState } from '@nextcloud/initial-state' +import { + showError, + showSuccess, +} from '@nextcloud/dialogs' +import { + findScheduleInboxAvailability, + getEmptySlots, + saveScheduleInboxAvailability, +} from '../service/CalendarService.js' +import { + enableUserStatusAutomation, + disableUserStatusAutomation, +} from '../service/PreferenceService.js' +import NcButton from '@nextcloud/vue/components/NcButton' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import NcTimezonePicker from '@nextcloud/vue/components/NcTimezonePicker' + +export default { + name: 'AvailabilityForm', + components: { + NcButton, + NcCheckboxRadioSwitch, + CalendarAvailability, + NcTimezonePicker, + }, + data() { + // Try to determine the current timezone, and fall back to UTC otherwise + const defaultTimezoneId = (new Intl.DateTimeFormat())?.resolvedOptions()?.timeZone ?? 'UTC' + + return { + loading: true, + saving: false, + timezone: defaultTimezoneId, + slots: getEmptySlots(), + automated: loadState('dav', 'user_status_automation') === 'yes', + } + }, + computed: { + timeZonePickerId() { + return `tz-${(Math.random() + 1).toString(36).substring(7)}` + }, + }, + async mounted() { + try { + const slotData = await findScheduleInboxAvailability() + if (!slotData) { + console.info('no availability is set') + this.slots = getEmptySlots() + } else { + const { slots, timezoneId } = slotData + this.slots = slots + if (timezoneId) { + this.timezone = timezoneId + } + console.info('availability loaded', this.slots, this.timezoneId) + } + } catch (e) { + console.error('could not load existing availability', e) + + showError(t('dav', 'Failed to load availability')) + } finally { + this.loading = false + } + }, + methods: { + async save() { + try { + this.saving = true + + await saveScheduleInboxAvailability(this.slots, this.timezone) + if (this.automated) { + await enableUserStatusAutomation() + } else { + await disableUserStatusAutomation() + } + + showSuccess(t('dav', 'Saved availability')) + } catch (e) { + console.error('could not save availability', e) + + showError(t('dav', 'Failed to save availability')) + } finally { + this.saving = false + } + }, + }, +} +</script> + +<style lang="scss" scoped> +:deep(.availability-day) { + padding: 0 10px 0 10px; + position: absolute; +} + +:deep(.availability-slots) { + display: flex; + white-space: normal; +} + +:deep(.availability-slot) { + display: flex; + flex-direction: row; + align-items: center; + flex-wrap: wrap; +} + +:deep(.availability-slot-group) { + display: flex; + flex-direction: column; +} + +:deep(.mx-input-wrapper) { + width: 85px; +} + +:deep(.mx-datepicker) { + width: 97px; +} + +:deep(.multiselect) { + border: 1px solid var(--color-border-dark); + width: 120px; +} + +.time-zone { + padding-block: 32px 12px; + padding-inline: 0 12px; + display: flex; + flex-wrap: wrap; + + &__heading { + margin-inline-end: calc(var(--default-grid-baseline) * 2); + line-height: var(--default-clickable-area); + font-weight: bold; + } +} + +.grid-table { + display: grid; + margin-bottom: 32px; + grid-column-gap: 24px; + grid-row-gap: 6px; + grid-template-columns: min-content auto min-content; + max-width: 500px; +} + +.button { + align-self: flex-end; +} + +:deep(.label-weekday) { + position: relative; + display: inline-flex; + padding-top: 4px; + align-self: center; +} + +:deep(.delete-slot) { + padding-bottom: unset; +} + +:deep(.add-another) { + align-self: center; +} + +.to-text { + padding-inline-end: 12px; +} + +.empty-content { + color: var(--color-text-lighter); + margin-block-start: var(--default-grid-baseline); + align-self: center; +} +</style> diff --git a/apps/dav/src/components/ExampleContactSettings.vue b/apps/dav/src/components/ExampleContactSettings.vue new file mode 100644 index 00000000000..cdfdc130189 --- /dev/null +++ b/apps/dav/src/components/ExampleContactSettings.vue @@ -0,0 +1,172 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <div class="example-contact-settings"> + <NcCheckboxRadioSwitch :checked="enableDefaultContact" + type="switch" + @update:model-value="updateEnableDefaultContact"> + {{ $t('dav', "Add example contact to user's address book when they first log in") }} + </NcCheckboxRadioSwitch> + <div v-if="enableDefaultContact" class="example-contact-settings__buttons"> + <ExampleContentDownloadButton :href="downloadUrl"> + <template #icon> + <IconAccount :size="20" /> + </template> + example_contact.vcf + </ExampleContentDownloadButton> + <NcButton type="secondary" + @click="toggleModal"> + <template #icon> + <IconUpload :size="20" /> + </template> + {{ $t('dav', 'Import contact') }} + </NcButton> + <NcButton v-if="hasCustomDefaultContact" + type="tertiary" + @click="resetContact"> + <template #icon> + <IconRestore :size="20" /> + </template> + {{ $t('dav', 'Reset to default') }} + </NcButton> + </div> + <NcDialog :open.sync="isModalOpen" + :name="$t('dav', 'Import contacts')" + :buttons="buttons"> + <div> + <p>{{ $t('dav', 'Importing a new .vcf file will delete the existing default contact and replace it with the new one. Do you want to continue?') }}</p> + </div> + </NcDialog> + <input id="example-contact-import" + ref="exampleContactImportInput" + :disabled="loading" + type="file" + accept=".vcf" + class="hidden-visually" + @change="processFile"> + </div> +</template> +<script> +import axios from '@nextcloud/axios' +import { generateUrl } from '@nextcloud/router' +import { loadState } from '@nextcloud/initial-state' +import { NcDialog, NcButton, NcCheckboxRadioSwitch } from '@nextcloud/vue' +import { showError, showSuccess } from '@nextcloud/dialogs' +import IconUpload from 'vue-material-design-icons/Upload.vue' +import IconRestore from 'vue-material-design-icons/Restore.vue' +import IconAccount from 'vue-material-design-icons/Account.vue' +import IconCancel from '@mdi/svg/svg/cancel.svg?raw' +import IconCheck from '@mdi/svg/svg/check.svg?raw' +import logger from '../service/logger.js' +import ExampleContentDownloadButton from './ExampleContentDownloadButton.vue' + +const enableDefaultContact = loadState('dav', 'enableDefaultContact') +const hasCustomDefaultContact = loadState('dav', 'hasCustomDefaultContact') + +export default { + name: 'ExampleContactSettings', + components: { + NcDialog, + NcButton, + NcCheckboxRadioSwitch, + IconUpload, + IconRestore, + IconAccount, + ExampleContentDownloadButton, + }, + data() { + return { + enableDefaultContact, + hasCustomDefaultContact, + isModalOpen: false, + loading: false, + buttons: [ + { + label: this.$t('dav', 'Cancel'), + icon: IconCancel, + callback: () => { this.isModalOpen = false }, + }, + { + label: this.$t('dav', 'Import'), + type: 'primary', + icon: IconCheck, + callback: () => { this.clickImportInput() }, + }, + ], + } + }, + computed: { + downloadUrl() { + return generateUrl('/apps/dav/api/defaultcontact/contact') + }, + }, + methods: { + updateEnableDefaultContact() { + axios.put(generateUrl('apps/dav/api/defaultcontact/config'), { + allow: !this.enableDefaultContact, + }).then(() => { + this.enableDefaultContact = !this.enableDefaultContact + }).catch(() => { + showError(this.$t('dav', 'Error while saving settings')) + }) + }, + toggleModal() { + this.isModalOpen = !this.isModalOpen + }, + clickImportInput() { + this.$refs.exampleContactImportInput.click() + }, + resetContact() { + this.loading = true + axios.put(generateUrl('/apps/dav/api/defaultcontact/contact')) + .then(() => { + this.hasCustomDefaultContact = false + showSuccess(this.$t('dav', 'Contact reset successfully')) + }) + .catch((error) => { + logger.error('Error importing contact:', { error }) + showError(this.$t('dav', 'Error while resetting contact')) + }) + .finally(() => { + this.loading = false + }) + }, + processFile(event) { + this.loading = true + + const file = event.target.files[0] + const reader = new FileReader() + + reader.onload = async () => { + this.isModalOpen = false + try { + await axios.put(generateUrl('/apps/dav/api/defaultcontact/contact'), { contactData: reader.result }) + this.hasCustomDefaultContact = true + showSuccess(this.$t('dav', 'Contact imported successfully')) + } catch (error) { + logger.error('Error importing contact:', { error }) + showError(this.$t('dav', 'Error while importing contact')) + } finally { + this.loading = false + event.target.value = '' + } + } + reader.readAsText(file) + }, + }, +} +</script> +<style lang="scss" scoped> +.example-contact-settings { + margin-block-start: 2rem; + + &__buttons { + display: flex; + gap: calc(var(--default-grid-baseline) * 2); + margin-top: calc(var(--default-grid-baseline) * 2); + } +} +</style> diff --git a/apps/dav/src/components/ExampleContentDownloadButton.vue b/apps/dav/src/components/ExampleContentDownloadButton.vue new file mode 100644 index 00000000000..6ee13e057bd --- /dev/null +++ b/apps/dav/src/components/ExampleContentDownloadButton.vue @@ -0,0 +1,57 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> + +<template> + <NcButton type="tertiary" :href="href"> + <template #icon> + <slot name="icon" /> + </template> + <div class="download-button"> + <span class="download-button__label"> + <slot name="default" /> + </span> + <IconDownload class="download-button__icon" + :size="20" /> + </div> + </NcButton> +</template> + +<script> +import { NcButton } from '@nextcloud/vue' +import IconDownload from 'vue-material-design-icons/Download.vue' + +export default { + name: 'ExampleContentDownloadButton', + components: { + NcButton, + IconDownload, + }, + props: { + href: { + type: String, + required: true, + }, + }, +} +</script> + +<style lang="scss" scoped> +.download-button { + display: flex; + max-width: 200px; + + &__label { + font-weight: initial; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + + &__icon { + margin-top: 2px; + margin-inline-start: var(--default-grid-baseline); + } +} +</style> diff --git a/apps/dav/src/components/ExampleEventSettings.vue b/apps/dav/src/components/ExampleEventSettings.vue new file mode 100644 index 00000000000..5d2053def50 --- /dev/null +++ b/apps/dav/src/components/ExampleEventSettings.vue @@ -0,0 +1,217 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> + +<template> + <div class="example-event-settings"> + <NcCheckboxRadioSwitch :checked="createExampleEvent" + :disabled="savingConfig" + type="switch" + @update:model-value="updateCreateExampleEvent"> + {{ t('dav', "Add example event to user's calendar when they first log in") }} + </NcCheckboxRadioSwitch> + <div v-if="createExampleEvent" + class="example-event-settings__buttons"> + <ExampleContentDownloadButton :href="downloadUrl"> + <template #icon> + <IconCalendarBlank :size="20" /> + </template> + example_event.ics + </ExampleContentDownloadButton> + <NcButton type="secondary" + @click="showImportModal = true"> + <template #icon> + <IconUpload :size="20" /> + </template> + {{ t('dav', 'Import calendar event') }} + </NcButton> + <NcButton v-if="hasCustomEvent" + type="tertiary" + :disabled="deleting" + @click="deleteCustomEvent"> + <template #icon> + <IconRestore :size="20" /> + </template> + {{ t('dav', 'Reset to default') }} + </NcButton> + </div> + <NcDialog :open.sync="showImportModal" + :name="t('dav', 'Import calendar event')"> + <div class="import-event-modal"> + <p> + {{ t('dav', 'Uploading a new event will overwrite the existing one.') }} + </p> + <input ref="event-file" + :disabled="uploading" + type="file" + accept=".ics,text/calendar" + class="import-event-modal__file-picker" + @change="selectFile"> + <div class="import-event-modal__buttons"> + <NcButton :disabled="uploading || !selectedFile" + type="primary" + @click="uploadCustomEvent()"> + <template #icon> + <IconUpload :size="20" /> + </template> + {{ t('dav', 'Upload event') }} + </NcButton> + </div> + </div> + </NcDialog> + </div> +</template> + +<script> +import { NcButton, NcCheckboxRadioSwitch, NcDialog } from '@nextcloud/vue' +import { loadState } from '@nextcloud/initial-state' +import IconCalendarBlank from 'vue-material-design-icons/CalendarBlank.vue' +import IconUpload from 'vue-material-design-icons/Upload.vue' +import IconRestore from 'vue-material-design-icons/Restore.vue' +import * as ExampleEventService from '../service/ExampleEventService.js' +import { showError, showSuccess } from '@nextcloud/dialogs' +import logger from '../service/logger.js' +import { generateUrl } from '@nextcloud/router' +import ExampleContentDownloadButton from './ExampleContentDownloadButton.vue' + +export default { + name: 'ExampleEventSettings', + components: { + NcButton, + NcCheckboxRadioSwitch, + NcDialog, + IconCalendarBlank, + IconUpload, + IconRestore, + ExampleContentDownloadButton, + }, + data() { + return { + createExampleEvent: loadState('dav', 'create_example_event', false), + hasCustomEvent: loadState('dav', 'has_custom_example_event', false), + showImportModal: false, + uploading: false, + deleting: false, + savingConfig: false, + selectedFile: undefined, + } + }, + computed: { + downloadUrl() { + return generateUrl('/apps/dav/api/exampleEvent/event') + }, + }, + methods: { + selectFile() { + this.selectedFile = this.$refs['event-file']?.files[0] + }, + async updateCreateExampleEvent() { + this.savingConfig = true + + const enable = !this.createExampleEvent + try { + await ExampleEventService.setCreateExampleEvent(enable) + } catch (error) { + showError(t('dav', 'Failed to save example event creation setting')) + logger.error('Failed to save example event creation setting', { + error, + enable, + }) + } finally { + this.savingConfig = false + } + + this.createExampleEvent = enable + }, + uploadCustomEvent() { + if (!this.selectedFile) { + return + } + + this.uploading = true + + const reader = new FileReader() + reader.addEventListener('load', async () => { + const ics = reader.result + + try { + await ExampleEventService.uploadExampleEvent(ics) + } catch (error) { + showError(t('dav', 'Failed to upload the example event')) + logger.error('Failed to upload example ICS', { + error, + ics, + }) + return + } finally { + this.uploading = false + } + + showSuccess(t('dav', 'Custom example event was saved successfully')) + this.showImportModal = false + this.hasCustomEvent = true + }) + reader.readAsText(this.selectedFile) + }, + async deleteCustomEvent() { + this.deleting = true + + try { + await ExampleEventService.deleteExampleEvent() + } catch (error) { + showError(t('dav', 'Failed to delete the custom example event')) + logger.error('Failed to delete the custom example event', { + error, + }) + return + } finally { + this.deleting = false + } + + showSuccess(t('dav', 'Custom example event was deleted successfully')) + this.hasCustomEvent = false + }, + }, +} +</script> + +<style lang="scss" scoped> +.example-event-settings { + margin-block: 2rem; + + &__buttons { + display: flex; + gap: calc(var(--default-grid-baseline) * 2); + margin-top: calc(var(--default-grid-baseline) * 2); + + &__download-link { + display: flex; + max-width: 100px; + + &__label { + text-decoration: underline; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + } + } +} + +.import-event-modal { + display: flex; + flex-direction: column; + gap: calc(var(--default-grid-baseline) * 2); + padding: calc(var(--default-grid-baseline) * 2); + + &__file-picker { + width: 100%; + } + + &__buttons { + display: flex; + justify-content: flex-end; + } +} +</style> diff --git a/apps/dav/src/dav/client.js b/apps/dav/src/dav/client.js index ff858e0492c..d286f6f48d6 100644 --- a/apps/dav/src/dav/client.js +++ b/apps/dav/src/dav/client.js @@ -1,39 +1,31 @@ -/* - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @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/>. +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import * as webdav from 'webdav' -import axios from '@nextcloud/axios' -import memoize from 'lodash/fp/memoize' +import { createClient } from 'webdav' +import memoize from 'lodash/fp/memoize.js' import { generateRemoteUrl } from '@nextcloud/router' -import { getCurrentUser } from '@nextcloud/auth' +import { getCurrentUser, getRequestToken, onRequestTokenUpdate } from '@nextcloud/auth' export const getClient = memoize((service) => { - // Add this so the server knows it is an request from the browser - axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest' + // init webdav client + const remote = generateRemoteUrl(`dav/${service}/${getCurrentUser().uid}`) + const client = createClient(remote) - // force our axios - const patcher = webdav.getPatcher() - patcher.patch('request', axios) + // set CSRF token header + const setHeaders = (token) => { + client.setHeaders({ + // Add this so the server knows it is an request from the browser + 'X-Requested-With': 'XMLHttpRequest', + // Inject user auth + requesttoken: token ?? '', + }) + } - return webdav.createClient( - generateRemoteUrl(`dav/${service}/${getCurrentUser().uid}`) - ) + // refresh headers when request token changes + onRequestTokenUpdate(setHeaders) + setHeaders(getRequestToken()) + + return client }) diff --git a/apps/dav/src/service/CalendarService.js b/apps/dav/src/service/CalendarService.js index 2b416d6b670..93b36b8e74f 100644 --- a/apps/dav/src/service/CalendarService.js +++ b/apps/dav/src/service/CalendarService.js @@ -1,26 +1,10 @@ /** - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @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/>. + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { getClient } from '../dav/client' -import logger from './logger' -import { parseXML } from 'webdav/dist/node/tools/dav' +import { getClient } from '../dav/client.js' +import logger from './logger.js' +import { parseXML } from 'webdav' import { slotsToVavailability, @@ -58,7 +42,7 @@ export async function findScheduleInboxAvailability() { </x0:propfind>`, }) - const xml = await parseXML(response.data) + const xml = await parseXML(await response.text()) if (!xml) { return undefined diff --git a/apps/dav/src/service/ExampleEventService.js b/apps/dav/src/service/ExampleEventService.js new file mode 100644 index 00000000000..a39e3641bd9 --- /dev/null +++ b/apps/dav/src/service/ExampleEventService.js @@ -0,0 +1,43 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { generateUrl } from '@nextcloud/router' +import axios from '@nextcloud/axios' + +/** + * Configure the creation of example events on a user's first login. + * + * @param {boolean} enable Whether to enable or disable the feature. + * @return {Promise<void>} + */ +export async function setCreateExampleEvent(enable) { + const url = generateUrl('/apps/dav/api/exampleEvent/enable') + await axios.post(url, { + enable, + }) +} + +/** + * Upload a custom example event. + * + * @param {string} ics The ICS data of the event. + * @return {Promise<void>} + */ +export async function uploadExampleEvent(ics) { + const url = generateUrl('/apps/dav/api/exampleEvent/event') + await axios.post(url, { + ics, + }) +} + +/** + * Delete a previously uploaded custom example event. + * + * @return {Promise<void>} + */ +export async function deleteExampleEvent() { + const url = generateUrl('/apps/dav/api/exampleEvent/event') + await axios.delete(url) +} diff --git a/apps/dav/src/service/PreferenceService.js b/apps/dav/src/service/PreferenceService.js index 6b8d29029b5..39b2c067c61 100644 --- a/apps/dav/src/service/PreferenceService.js +++ b/apps/dav/src/service/PreferenceService.js @@ -1,22 +1,6 @@ /** - * @copyright 2022 Joas Schilling <coding@schilljs.com> - * - * @author Joas Schilling <coding@schilljs.com> - * - * @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/>. + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import axios from '@nextcloud/axios' @@ -33,7 +17,7 @@ export async function enableUserStatusAutomation() { }), { configValue: 'yes', - } + }, ) } @@ -45,6 +29,6 @@ export async function disableUserStatusAutomation() { generateOcsUrl('/apps/provisioning_api/api/v1/config/users/{appId}/{configKey}', { appId: 'dav', configKey: 'user_status_automation', - }) + }), ) } diff --git a/apps/dav/src/service/logger.js b/apps/dav/src/service/logger.js index dd6ec9163a6..cb7f1a95103 100644 --- a/apps/dav/src/service/logger.js +++ b/apps/dav/src/service/logger.js @@ -1,22 +1,6 @@ -/* - * @copyright 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author 2021 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @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/>. +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { getLoggerBuilder } from '@nextcloud/logger' diff --git a/apps/dav/src/settings-example-content.js b/apps/dav/src/settings-example-content.js new file mode 100644 index 00000000000..fdeba642a67 --- /dev/null +++ b/apps/dav/src/settings-example-content.js @@ -0,0 +1,18 @@ +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import Vue from 'vue' +import { translate } from '@nextcloud/l10n' +import ExampleContentSettingsSection from './views/ExampleContentSettingsSection.vue' + +Vue.mixin({ + methods: { + t: translate, + $t: translate, + }, +}) + +const View = Vue.extend(ExampleContentSettingsSection); + +(new View({})).$mount('#settings-example-content') diff --git a/apps/dav/src/settings-personal-availability.js b/apps/dav/src/settings-personal-availability.js index b0d6b19aa8a..b24144b81f0 100644 --- a/apps/dav/src/settings-personal-availability.js +++ b/apps/dav/src/settings-personal-availability.js @@ -1,6 +1,10 @@ +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ import Vue from 'vue' import { translate } from '@nextcloud/l10n' -import Availability from './views/Availability' +import Availability from './views/Availability.vue' Vue.prototype.$t = translate diff --git a/apps/dav/src/settings.js b/apps/dav/src/settings.js index 6744f22ad23..c69a8b03614 100644 --- a/apps/dav/src/settings.js +++ b/apps/dav/src/settings.js @@ -1,7 +1,11 @@ +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ import Vue from 'vue' import { loadState } from '@nextcloud/initial-state' import { translate } from '@nextcloud/l10n' -import CalDavSettings from './views/CalDavSettings' +import CalDavSettings from './views/CalDavSettings.vue' Vue.prototype.$t = translate @@ -13,12 +17,12 @@ const CalDavSettingsView = new View({ sendInvitations: loadState('dav', 'sendInvitations'), generateBirthdayCalendar: loadState( 'dav', - 'generateBirthdayCalendar' + 'generateBirthdayCalendar', ), sendEventReminders: loadState('dav', 'sendEventReminders'), - sendEventRemindersToSharedGroupMembers: loadState( + sendEventRemindersToSharedUsers: loadState( 'dav', - 'sendEventRemindersToSharedGroupMembers' + 'sendEventRemindersToSharedUsers', ), sendEventRemindersPush: loadState('dav', 'sendEventRemindersPush'), } diff --git a/apps/dav/src/utils/date.js b/apps/dav/src/utils/date.js new file mode 100644 index 00000000000..de1d65e310d --- /dev/null +++ b/apps/dav/src/utils/date.js @@ -0,0 +1,17 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Format a date as 'YYYY-MM-DD'. + * + * @param {Date} date A date instance to format. + * @return {string} 'YYYY-MM-DD' + */ +export function formatDateAsYMD(date) { + const year = date.getFullYear() + const month = (date.getMonth() + 1).toString().padStart(2, '0') + const day = date.getDate().toString().padStart(2, '0') + return `${year}-${month}-${day}` +} diff --git a/apps/dav/src/views/Availability.vue b/apps/dav/src/views/Availability.vue index e0128a59e0a..1922f5b706e 100644 --- a/apps/dav/src/views/Availability.vue +++ b/apps/dav/src/views/Availability.vue @@ -1,208 +1,40 @@ +<!-- + - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <NcSettingsSection :title="$t('dav', 'Availability')" - :description="$t('dav', 'If you configure your working hours, other users will see when you are out of office when they book a meeting.')"> - <div class="time-zone"> - <strong> - {{ $t('dav', 'Time zone:') }} - </strong> - <span class="time-zone-text"> - <NcTimezonePicker v-model="timezone" /> - </span> - </div> - - <CalendarAvailability :slots.sync="slots" - :loading="loading" - :l10n-to="$t('dav', 'to')" - :l10n-delete-slot="$t('dav', 'Delete slot')" - :l10n-empty-day="$t('dav', 'No working hours set')" - :l10n-add-slot="$t('dav', 'Add slot')" - :l10n-monday="$t('dav', 'Monday')" - :l10n-tuesday="$t('dav', 'Tuesday')" - :l10n-wednesday="$t('dav', 'Wednesday')" - :l10n-thursday="$t('dav', 'Thursday')" - :l10n-friday="$t('dav', 'Friday')" - :l10n-saturday="$t('dav', 'Saturday')" - :l10n-sunday="$t('dav', 'Sunday')" /> - - <NcCheckboxRadioSwitch :checked.sync="automated"> - {{ $t('dav', 'Automatically set user status to "Do not disturb" outside of availability to mute all notifications.') }} - </NcCheckboxRadioSwitch> - - <NcButton :disabled="loading || saving" - type="primary" - @click="save"> - {{ $t('dav', 'Save') }} - </NcButton> - </NcSettingsSection> + <div> + <NcSettingsSection id="availability" + :name="$t('dav', 'Availability')" + :description="$t('dav', 'If you configure your working hours, other people will see when you are out of office when they book a meeting.')"> + <AvailabilityForm /> + </NcSettingsSection> + <NcSettingsSection v-if="!hideAbsenceSettings" + id="absence" + :name="$t('dav', 'Absence')" + :description="$t('dav', 'Configure your next absence period.')"> + <AbsenceForm /> + </NcSettingsSection> + </div> </template> <script> -import { CalendarAvailability } from '@nextcloud/calendar-availability-vue' +import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection' +import AbsenceForm from '../components/AbsenceForm.vue' +import AvailabilityForm from '../components/AvailabilityForm.vue' import { loadState } from '@nextcloud/initial-state' -import { - showError, - showSuccess, -} from '@nextcloud/dialogs' -import { - findScheduleInboxAvailability, - getEmptySlots, - saveScheduleInboxAvailability, -} from '../service/CalendarService' -import { - enableUserStatusAutomation, - disableUserStatusAutomation, -} from '../service/PreferenceService' -import NcButton from '@nextcloud/vue/dist/Components/NcButton' -import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch' -import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection' -import NcTimezonePicker from '@nextcloud/vue/dist/Components/NcTimezonePicker' export default { name: 'Availability', components: { - NcButton, - NcCheckboxRadioSwitch, - CalendarAvailability, NcSettingsSection, - NcTimezonePicker, + AbsenceForm, + AvailabilityForm, }, data() { - // Try to determine the current timezone, and fall back to UTC otherwise - const defaultTimezoneId = (new Intl.DateTimeFormat())?.resolvedOptions()?.timeZone ?? 'UTC' - return { - loading: true, - saving: false, - timezone: defaultTimezoneId, - slots: getEmptySlots(), - automated: loadState('dav', 'user_status_automation') === 'yes', + hideAbsenceSettings: loadState('dav', 'hide_absence_settings', true), } }, - async mounted() { - try { - const slotData = await findScheduleInboxAvailability() - if (!slotData) { - console.info('no availability is set') - this.slots = getEmptySlots() - } else { - const { slots, timezoneId } = slotData - this.slots = slots - if (timezoneId) { - this.timezone = timezoneId - } - console.info('availability loaded', this.slots, this.timezoneId) - } - } catch (e) { - console.error('could not load existing availability', e) - - showError(t('dav', 'Failed to load availability')) - } finally { - this.loading = false - } - }, - methods: { - async save() { - try { - this.saving = true - - await saveScheduleInboxAvailability(this.slots, this.timezone) - if (this.automated) { - await enableUserStatusAutomation() - } else { - await disableUserStatusAutomation() - } - - showSuccess(t('dav', 'Saved availability')) - } catch (e) { - console.error('could not save availability', e) - - showError(t('dav', 'Failed to save availability')) - } finally { - this.saving = false - } - }, - }, } </script> - -<style lang="scss" scoped> -.availability-day { - padding: 0 10px 0 10px; - position: absolute; -} -.availability-slots { - display: flex; - white-space: nowrap; -} -.availability-slot { - display: flex; - flex-direction: row; - align-items: center; -} -.availability-slot-group { - display: flex; - flex-direction: column; -} -::v-deep .mx-input-wrapper { - width: 85px; -} -::v-deep .mx-datepicker { - width: 97px; -} -::v-deep .multiselect { - border: 1px solid var(--color-border-dark); - width: 120px; -} -.time-zone { - padding: 32px 12px 12px 0; -} -.grid-table { - display: grid; - margin-bottom: 32px; - grid-column-gap: 24px; - grid-row-gap: 6px; - grid-template-columns: min-content min-content min-content; -} -.button { - align-self: flex-end; -} -.label-weekday { - position: relative; - display: inline-flex; - padding-top: 4px; -} -.delete-slot { - background-color: transparent; - border: none; - padding-bottom: 12px; - opacity: .5; - &:hover { - opacity: 1; - } -} - -.add-another { - background-color: transparent; - border: none; - opacity: .5; - display: inline-flex; - padding: 0; - margin: 0; - margin-bottom: 3px; - - &:hover { - opacity: 1; - } -} -.to-text { - padding-right: 12px; -} -.time-zone-text{ - padding-left: 22px; -} -.empty-content { - color: var(--color-text-lighter); - margin-top: 4px; -} - -</style> diff --git a/apps/dav/src/views/CalDavSettings.spec.js b/apps/dav/src/views/CalDavSettings.spec.js index 5c81c6259a1..7a4345b3ddf 100644 --- a/apps/dav/src/views/CalDavSettings.spec.js +++ b/apps/dav/src/views/CalDavSettings.spec.js @@ -1,38 +1,35 @@ +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ import { render } from '@testing-library/vue' -import CalDavSettings from './CalDavSettings' -// eslint-disable-next-line no-unused-vars -import { generateUrl } from '@nextcloud/router' +import { beforeEach, describe, expect, test, vi } from 'vitest' -jest.mock('@nextcloud/axios') -jest.mock('@nextcloud/router', () => { +import CalDavSettings from './CalDavSettings.vue' + +vi.mock('@nextcloud/axios') +vi.mock('@nextcloud/router', () => { return { generateUrl(url) { return url }, } }) -jest.mock('@nextcloud/initial-state', () => { +vi.mock('@nextcloud/initial-state', () => { return { - loadState: jest.fn(() => 'https://docs.nextcloud.com/server/23/go.php?to=user-sync-calendars'), + loadState: vi.fn(() => 'https://docs.nextcloud.com/server/23/go.php?to=user-sync-calendars'), } }) describe('CalDavSettings', () => { - const originalOC = global.OC - const originalOCP = global.OCP - beforeEach(() => { - global.OC = { requestToken: 'secret' } - global.OCP = { + window.OC = { requestToken: 'secret' } + window.OCP = { AppConfig: { - setValue: jest.fn(), + setValue: vi.fn(), }, } }) - afterAll(() => { - global.OC = originalOC - global.OCP = originalOCP - }) test('interactions', async () => { const TLUtils = render( @@ -43,34 +40,34 @@ describe('CalDavSettings', () => { sendInvitations: true, generateBirthdayCalendar: true, sendEventReminders: true, - sendEventRemindersToSharedGroupMembers: true, + sendEventRemindersToSharedUsers: true, sendEventRemindersPush: true, } }, }, Vue => { - Vue.prototype.$t = jest.fn((app, text) => text) - } + Vue.prototype.$t = vi.fn((app, text) => text) + }, ) expect(TLUtils.container).toMatchSnapshot() const sendInvitations = TLUtils.getByLabelText( - 'Send invitations to attendees' + 'Send invitations to attendees', ) expect(sendInvitations).toBeChecked() const generateBirthdayCalendar = TLUtils.getByLabelText( - 'Automatically generate a birthday calendar' + 'Automatically generate a birthday calendar', ) expect(generateBirthdayCalendar).toBeChecked() const sendEventReminders = TLUtils.getByLabelText( - 'Send notifications for events' + 'Send notifications for events', ) expect(sendEventReminders).toBeChecked() - const sendEventRemindersToSharedGroupMembers = TLUtils.getByLabelText( - 'Send reminder notifications to calendar sharees as well' + const sendEventRemindersToSharedUsers = TLUtils.getByLabelText( + 'Send reminder notifications to calendar sharees as well', ) - expect(sendEventRemindersToSharedGroupMembers).toBeChecked() + expect(sendEventRemindersToSharedUsers).toBeChecked() const sendEventRemindersPush = TLUtils.getByLabelText( - 'Enable notifications for events via push' + 'Enable notifications for events via push', ) expect(sendEventRemindersPush).toBeChecked() @@ -114,7 +111,7 @@ describe('CalDavSettings', () => { 'no' ) - expect(sendEventRemindersToSharedGroupMembers).toBeDisabled() + expect(sendEventRemindersToSharedUsers).toBeDisabled() expect(sendEventRemindersPush).toBeDisabled() OCP.AppConfig.setValue.mockClear() @@ -126,7 +123,7 @@ describe('CalDavSettings', () => { 'yes' ) - expect(sendEventRemindersToSharedGroupMembers).toBeEnabled() + expect(sendEventRemindersToSharedUsers).toBeEnabled() expect(sendEventRemindersPush).toBeEnabled() */ }) diff --git a/apps/dav/src/views/CalDavSettings.vue b/apps/dav/src/views/CalDavSettings.vue index 776f32bff36..6be67cf93ff 100644 --- a/apps/dav/src/views/CalDavSettings.vue +++ b/apps/dav/src/views/CalDavSettings.vue @@ -1,5 +1,9 @@ +<!-- + - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <NcSettingsSection :title="$t('dav', 'Calendar server')" + <NcSettingsSection :name="$t('dav', 'Calendar server')" :doc-url="userSyncCalendarsDocUrl"> <!-- Can use v-html as: - $t passes the translated string through DOMPurify.sanitize, @@ -51,7 +55,7 @@ </p> <p class="indented"> <NcCheckboxRadioSwitch id="caldavSendEventRemindersToSharedGroupMembers" - :checked.sync="sendEventRemindersToSharedGroupMembers" + :checked.sync="sendEventRemindersToSharedUsers" type="switch" :disabled="!sendEventReminders"> {{ $t('dav', 'Send reminder notifications to calendar sharees as well' ) }} @@ -75,8 +79,8 @@ import axios from '@nextcloud/axios' import { generateUrl } from '@nextcloud/router' import { loadState } from '@nextcloud/initial-state' -import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection' -import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch' +import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' const userSyncCalendarsDocUrl = loadState('dav', 'userSyncCalendarsDocUrl', '#') @@ -124,17 +128,17 @@ export default { OCP.AppConfig.setValue( 'dav', 'sendInvitations', - value ? 'yes' : 'no' + value ? 'yes' : 'no', ) }, sendEventReminders(value) { OCP.AppConfig.setValue('dav', 'sendEventReminders', value ? 'yes' : 'no') }, - sendEventRemindersToSharedGroupMembers(value) { + sendEventRemindersToSharedUsers(value) { OCP.AppConfig.setValue( 'dav', - 'sendEventRemindersToSharedGroupMembers', - value ? 'yes' : 'no' + 'sendEventRemindersToSharedUsers', + value ? 'yes' : 'no', ) }, sendEventRemindersPush(value) { @@ -146,12 +150,13 @@ export default { <style scoped> .indented { - padding-left: 28px; + padding-inline-start: 28px; } /** Use deep selector to affect v-html */ - * >>> a { + * :deep(a) { text-decoration: underline; } + .settings-hint { margin-top: -.2em; margin-bottom: 1em; diff --git a/apps/dav/src/views/ExampleContentSettingsSection.vue b/apps/dav/src/views/ExampleContentSettingsSection.vue new file mode 100644 index 00000000000..3ee2d9e8648 --- /dev/null +++ b/apps/dav/src/views/ExampleContentSettingsSection.vue @@ -0,0 +1,38 @@ +<!-- + - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcSettingsSection id="example-content" + :name="$t('dav', 'Example content')" + class="example-content-setting" + :description="$t('dav', 'Example content serves to showcase the features of Nextcloud. Default content is shipped with Nextcloud, and can be replaced by custom content.')"> + <ExampleContactSettings v-if="hasContactsApp" /> + <ExampleEventSettings v-if="hasCalendarApp" /> + </NcSettingsSection> +</template> + +<script> +import { loadState } from '@nextcloud/initial-state' +import { NcSettingsSection } from '@nextcloud/vue' +import ExampleEventSettings from '../components/ExampleEventSettings.vue' +import ExampleContactSettings from '../components/ExampleContactSettings.vue' + +export default { + name: 'ExampleContentSettingsSection', + components: { + NcSettingsSection, + ExampleContactSettings, + ExampleEventSettings, + }, + computed: { + hasContactsApp() { + return loadState('dav', 'contactsEnabled') + }, + hasCalendarApp() { + return loadState('dav', 'calendarEnabled') + }, + }, +} +</script> diff --git a/apps/dav/src/views/__snapshots__/CalDavSettings.spec.js.snap b/apps/dav/src/views/__snapshots__/CalDavSettings.spec.js.snap index 5f4f0d1407d..fdbe09f5b5e 100644 --- a/apps/dav/src/views/__snapshots__/CalDavSettings.spec.js.snap +++ b/apps/dav/src/views/__snapshots__/CalDavSettings.spec.js.snap @@ -1,30 +1,30 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`CalDavSettings interactions 1`] = ` +exports[`CalDavSettings > interactions 1`] = ` <div> <div class="settings-section settings-section--limit-width" - data-v-97259c4c="" + data-v-6b8d4c30="" + data-v-6f6953b5="" > <h2 - class="settings-section__title" - data-v-97259c4c="" + class="settings-section__name" + data-v-6f6953b5="" > - - Calendar server - + Calendar server <a aria-label="External documentation for Calendar server" class="settings-section__info" - data-v-97259c4c="" + data-v-6f6953b5="" href="https://docs.nextcloud.com/server/23/go.php?to=user-sync-calendars" - role="note" + rel="noreferrer nofollow" + target="_blank" title="External documentation for Calendar server" > <span aria-hidden="true" class="material-design-icon help-circle-icon" - data-v-97259c4c="" + data-v-6f6953b5="" role="img" > <svg @@ -43,12 +43,11 @@ exports[`CalDavSettings interactions 1`] = ` </span> </a> </h2> - <!----> - <p class="settings-hint" - data-v-97259c4c="" + data-v-6b8d4c30="" + data-v-6f6953b5="" > Also install the <a @@ -67,57 +66,69 @@ exports[`CalDavSettings interactions 1`] = ` </a> . </p> - <p - data-v-97259c4c="" + data-v-6b8d4c30="" + data-v-6f6953b5="" > <span class="checkbox-radio-switch checkbox-radio-switch-switch checkbox-radio-switch--checked" - data-v-133c8c94="" - data-v-97259c4c="" - style="--icon-size: 36px;" + data-v-6b8d4c30="" + data-v-6f6953b5="" + data-v-f275cf53="" + style="--icon-size: 36px; --icon-height: 16px;" > - <label - class="checkbox-radio-switch__label" - data-v-133c8c94="" - for="caldavSendInvitations" + <input + aria-labelledby="caldavSendInvitations-label" + class="checkbox-radio-switch__input" + data-v-f275cf53="" + id="caldavSendInvitations" + type="checkbox" + value="" + /> + <span + class="checkbox-content checkbox-radio-switch__content checkbox-content-switch checkbox-content--has-text" + data-v-3714b019="" + data-v-f275cf53="" + id="caldavSendInvitations-label" > - <input - class="checkbox-radio-switch__input" - data-v-133c8c94="" - id="caldavSendInvitations" - type="checkbox" - value="" - /> - <span aria-hidden="true" - class="material-design-icon toggle-switch-icon checkbox-radio-switch__icon" - data-v-133c8c94="" - role="img" + class="checkbox-content__icon checkbox-content__icon--checked checkbox-radio-switch__icon" + data-v-3714b019="" + inert="inert" > - <svg - class="material-design-icon__svg" - fill="currentColor" - height="36" - viewBox="0 0 24 24" - width="36" + <span + aria-hidden="true" + class="material-design-icon toggle-switch-icon" + data-v-3714b019="" + role="img" > - <path - d="M17,7H7A5,5 0 0,0 2,12A5,5 0 0,0 7,17H17A5,5 0 0,0 22,12A5,5 0 0,0 17,7M17,15A3,3 0 0,1 14,12A3,3 0 0,1 17,9A3,3 0 0,1 20,12A3,3 0 0,1 17,15Z" + <svg + class="material-design-icon__svg" + fill="currentColor" + height="36" + viewBox="0 0 24 24" + width="36" > - <!----> - </path> - </svg> + <path + d="M17,7H7A5,5 0 0,0 2,12A5,5 0 0,0 7,17H17A5,5 0 0,0 22,12A5,5 0 0,0 17,7M17,15A3,3 0 0,1 14,12A3,3 0 0,1 17,9A3,3 0 0,1 20,12A3,3 0 0,1 17,15Z" + > + <!----> + </path> + </svg> + </span> </span> - - Send invitations to attendees - - </label> + <span + class="checkbox-content__text checkbox-radio-switch__text" + data-v-3714b019="" + > + Send invitations to attendees + </span> + </span> </span> - <em - data-v-97259c4c="" + data-v-6b8d4c30="" + data-v-6f6953b5="" > Please make sure to properly set up <a @@ -128,126 +139,146 @@ exports[`CalDavSettings interactions 1`] = ` . </em> </p> - <p - data-v-97259c4c="" + data-v-6b8d4c30="" + data-v-6f6953b5="" > <span class="checkbox-radio-switch checkbox checkbox-radio-switch-switch checkbox-radio-switch--checked" - data-v-133c8c94="" - data-v-97259c4c="" - style="--icon-size: 36px;" + data-v-6b8d4c30="" + data-v-6f6953b5="" + data-v-f275cf53="" + style="--icon-size: 36px; --icon-height: 16px;" > - <label - class="checkbox-radio-switch__label" - data-v-133c8c94="" - for="caldavGenerateBirthdayCalendar" + <input + aria-labelledby="caldavGenerateBirthdayCalendar-label" + class="checkbox-radio-switch__input" + data-v-f275cf53="" + id="caldavGenerateBirthdayCalendar" + type="checkbox" + value="" + /> + <span + class="checkbox-content checkbox-radio-switch__content checkbox-content-switch checkbox-content--has-text" + data-v-3714b019="" + data-v-f275cf53="" + id="caldavGenerateBirthdayCalendar-label" > - <input - class="checkbox-radio-switch__input" - data-v-133c8c94="" - id="caldavGenerateBirthdayCalendar" - type="checkbox" - value="" - /> - <span aria-hidden="true" - class="material-design-icon toggle-switch-icon checkbox-radio-switch__icon" - data-v-133c8c94="" - role="img" + class="checkbox-content__icon checkbox-content__icon--checked checkbox-radio-switch__icon" + data-v-3714b019="" + inert="inert" > - <svg - class="material-design-icon__svg" - fill="currentColor" - height="36" - viewBox="0 0 24 24" - width="36" + <span + aria-hidden="true" + class="material-design-icon toggle-switch-icon" + data-v-3714b019="" + role="img" > - <path - d="M17,7H7A5,5 0 0,0 2,12A5,5 0 0,0 7,17H17A5,5 0 0,0 22,12A5,5 0 0,0 17,7M17,15A3,3 0 0,1 14,12A3,3 0 0,1 17,9A3,3 0 0,1 20,12A3,3 0 0,1 17,15Z" + <svg + class="material-design-icon__svg" + fill="currentColor" + height="36" + viewBox="0 0 24 24" + width="36" > - <!----> - </path> - </svg> + <path + d="M17,7H7A5,5 0 0,0 2,12A5,5 0 0,0 7,17H17A5,5 0 0,0 22,12A5,5 0 0,0 17,7M17,15A3,3 0 0,1 14,12A3,3 0 0,1 17,9A3,3 0 0,1 20,12A3,3 0 0,1 17,15Z" + > + <!----> + </path> + </svg> + </span> </span> - - Automatically generate a birthday calendar - - </label> + <span + class="checkbox-content__text checkbox-radio-switch__text" + data-v-3714b019="" + > + Automatically generate a birthday calendar + </span> + </span> </span> - <em - data-v-97259c4c="" + data-v-6b8d4c30="" + data-v-6f6953b5="" > - - Birthday calendars will be generated by a background job. - + Birthday calendars will be generated by a background job. </em> - <br - data-v-97259c4c="" + data-v-6b8d4c30="" + data-v-6f6953b5="" /> - <em - data-v-97259c4c="" + data-v-6b8d4c30="" + data-v-6f6953b5="" > - - Hence they will not be available immediately after enabling but will show up after some time. - + Hence they will not be available immediately after enabling but will show up after some time. </em> </p> - <p - data-v-97259c4c="" + data-v-6b8d4c30="" + data-v-6f6953b5="" > <span class="checkbox-radio-switch checkbox-radio-switch-switch checkbox-radio-switch--checked" - data-v-133c8c94="" - data-v-97259c4c="" - style="--icon-size: 36px;" + data-v-6b8d4c30="" + data-v-6f6953b5="" + data-v-f275cf53="" + style="--icon-size: 36px; --icon-height: 16px;" > - <label - class="checkbox-radio-switch__label" - data-v-133c8c94="" - for="caldavSendEventReminders" + <input + aria-labelledby="caldavSendEventReminders-label" + class="checkbox-radio-switch__input" + data-v-f275cf53="" + id="caldavSendEventReminders" + type="checkbox" + value="" + /> + <span + class="checkbox-content checkbox-radio-switch__content checkbox-content-switch checkbox-content--has-text" + data-v-3714b019="" + data-v-f275cf53="" + id="caldavSendEventReminders-label" > - <input - class="checkbox-radio-switch__input" - data-v-133c8c94="" - id="caldavSendEventReminders" - type="checkbox" - value="" - /> - <span aria-hidden="true" - class="material-design-icon toggle-switch-icon checkbox-radio-switch__icon" - data-v-133c8c94="" - role="img" + class="checkbox-content__icon checkbox-content__icon--checked checkbox-radio-switch__icon" + data-v-3714b019="" + inert="inert" > - <svg - class="material-design-icon__svg" - fill="currentColor" - height="36" - viewBox="0 0 24 24" - width="36" + <span + aria-hidden="true" + class="material-design-icon toggle-switch-icon" + data-v-3714b019="" + role="img" > - <path - d="M17,7H7A5,5 0 0,0 2,12A5,5 0 0,0 7,17H17A5,5 0 0,0 22,12A5,5 0 0,0 17,7M17,15A3,3 0 0,1 14,12A3,3 0 0,1 17,9A3,3 0 0,1 20,12A3,3 0 0,1 17,15Z" + <svg + class="material-design-icon__svg" + fill="currentColor" + height="36" + viewBox="0 0 24 24" + width="36" > - <!----> - </path> - </svg> + <path + d="M17,7H7A5,5 0 0,0 2,12A5,5 0 0,0 7,17H17A5,5 0 0,0 22,12A5,5 0 0,0 17,7M17,15A3,3 0 0,1 14,12A3,3 0 0,1 17,9A3,3 0 0,1 20,12A3,3 0 0,1 17,15Z" + > + <!----> + </path> + </svg> + </span> + </span> + <span + class="checkbox-content__text checkbox-radio-switch__text" + data-v-3714b019="" + > + Send notifications for events </span> - - Send notifications for events - - </label> + </span> </span> - <em - data-v-97259c4c="" + data-v-6b8d4c30="" + data-v-6f6953b5="" > Please make sure to properly set up <a @@ -257,125 +288,145 @@ exports[`CalDavSettings interactions 1`] = ` </a> . </em> - <br - data-v-97259c4c="" + data-v-6b8d4c30="" + data-v-6f6953b5="" /> - <em - data-v-97259c4c="" + data-v-6b8d4c30="" + data-v-6f6953b5="" > - - Notifications are sent via background jobs, so these must occur often enough. - + Notifications are sent via background jobs, so these must occur often enough. </em> </p> - <p class="indented" - data-v-97259c4c="" + data-v-6b8d4c30="" + data-v-6f6953b5="" > <span class="checkbox-radio-switch checkbox-radio-switch-switch checkbox-radio-switch--checked" - data-v-133c8c94="" - data-v-97259c4c="" - style="--icon-size: 36px;" + data-v-6b8d4c30="" + data-v-6f6953b5="" + data-v-f275cf53="" + style="--icon-size: 36px; --icon-height: 16px;" > - <label - class="checkbox-radio-switch__label" - data-v-133c8c94="" - for="caldavSendEventRemindersToSharedGroupMembers" + <input + aria-labelledby="caldavSendEventRemindersToSharedGroupMembers-label" + class="checkbox-radio-switch__input" + data-v-f275cf53="" + id="caldavSendEventRemindersToSharedGroupMembers" + type="checkbox" + value="" + /> + <span + class="checkbox-content checkbox-radio-switch__content checkbox-content-switch checkbox-content--has-text" + data-v-3714b019="" + data-v-f275cf53="" + id="caldavSendEventRemindersToSharedGroupMembers-label" > - <input - class="checkbox-radio-switch__input" - data-v-133c8c94="" - id="caldavSendEventRemindersToSharedGroupMembers" - type="checkbox" - value="" - /> - <span aria-hidden="true" - class="material-design-icon toggle-switch-icon checkbox-radio-switch__icon" - data-v-133c8c94="" - role="img" + class="checkbox-content__icon checkbox-content__icon--checked checkbox-radio-switch__icon" + data-v-3714b019="" + inert="inert" > - <svg - class="material-design-icon__svg" - fill="currentColor" - height="36" - viewBox="0 0 24 24" - width="36" + <span + aria-hidden="true" + class="material-design-icon toggle-switch-icon" + data-v-3714b019="" + role="img" > - <path - d="M17,7H7A5,5 0 0,0 2,12A5,5 0 0,0 7,17H17A5,5 0 0,0 22,12A5,5 0 0,0 17,7M17,15A3,3 0 0,1 14,12A3,3 0 0,1 17,9A3,3 0 0,1 20,12A3,3 0 0,1 17,15Z" + <svg + class="material-design-icon__svg" + fill="currentColor" + height="36" + viewBox="0 0 24 24" + width="36" > - <!----> - </path> - </svg> + <path + d="M17,7H7A5,5 0 0,0 2,12A5,5 0 0,0 7,17H17A5,5 0 0,0 22,12A5,5 0 0,0 17,7M17,15A3,3 0 0,1 14,12A3,3 0 0,1 17,9A3,3 0 0,1 20,12A3,3 0 0,1 17,15Z" + > + <!----> + </path> + </svg> + </span> + </span> + <span + class="checkbox-content__text checkbox-radio-switch__text" + data-v-3714b019="" + > + Send reminder notifications to calendar sharees as well </span> - - Send reminder notifications to calendar sharees as well - - </label> + </span> </span> - <em - data-v-97259c4c="" + data-v-6b8d4c30="" + data-v-6f6953b5="" > - - Reminders are always sent to organizers and attendees. - + Reminders are always sent to organizers and attendees. </em> </p> - <p class="indented" - data-v-97259c4c="" + data-v-6b8d4c30="" + data-v-6f6953b5="" > <span class="checkbox-radio-switch checkbox-radio-switch-switch checkbox-radio-switch--checked" - data-v-133c8c94="" - data-v-97259c4c="" - style="--icon-size: 36px;" + data-v-6b8d4c30="" + data-v-6f6953b5="" + data-v-f275cf53="" + style="--icon-size: 36px; --icon-height: 16px;" > - <label - class="checkbox-radio-switch__label" - data-v-133c8c94="" - for="caldavSendEventRemindersPush" + <input + aria-labelledby="caldavSendEventRemindersPush-label" + class="checkbox-radio-switch__input" + data-v-f275cf53="" + id="caldavSendEventRemindersPush" + type="checkbox" + value="" + /> + <span + class="checkbox-content checkbox-radio-switch__content checkbox-content-switch checkbox-content--has-text" + data-v-3714b019="" + data-v-f275cf53="" + id="caldavSendEventRemindersPush-label" > - <input - class="checkbox-radio-switch__input" - data-v-133c8c94="" - id="caldavSendEventRemindersPush" - type="checkbox" - value="" - /> - <span aria-hidden="true" - class="material-design-icon toggle-switch-icon checkbox-radio-switch__icon" - data-v-133c8c94="" - role="img" + class="checkbox-content__icon checkbox-content__icon--checked checkbox-radio-switch__icon" + data-v-3714b019="" + inert="inert" > - <svg - class="material-design-icon__svg" - fill="currentColor" - height="36" - viewBox="0 0 24 24" - width="36" + <span + aria-hidden="true" + class="material-design-icon toggle-switch-icon" + data-v-3714b019="" + role="img" > - <path - d="M17,7H7A5,5 0 0,0 2,12A5,5 0 0,0 7,17H17A5,5 0 0,0 22,12A5,5 0 0,0 17,7M17,15A3,3 0 0,1 14,12A3,3 0 0,1 17,9A3,3 0 0,1 20,12A3,3 0 0,1 17,15Z" + <svg + class="material-design-icon__svg" + fill="currentColor" + height="36" + viewBox="0 0 24 24" + width="36" > - <!----> - </path> - </svg> + <path + d="M17,7H7A5,5 0 0,0 2,12A5,5 0 0,0 7,17H17A5,5 0 0,0 22,12A5,5 0 0,0 17,7M17,15A3,3 0 0,1 14,12A3,3 0 0,1 17,9A3,3 0 0,1 20,12A3,3 0 0,1 17,15Z" + > + <!----> + </path> + </svg> + </span> + </span> + <span + class="checkbox-content__text checkbox-radio-switch__text" + data-v-3714b019="" + > + Enable notifications for events via push </span> - - Enable notifications for events via push - - </label> + </span> </span> </p> </div> diff --git a/apps/dav/src/views/__snapshots__/CalDavSettings.spec.js.snap.license b/apps/dav/src/views/__snapshots__/CalDavSettings.spec.js.snap.license new file mode 100644 index 00000000000..b8f52265f1f --- /dev/null +++ b/apps/dav/src/views/__snapshots__/CalDavSettings.spec.js.snap.license @@ -0,0 +1,2 @@ +SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors +SPDX-License-Identifier: AGPL-3.0-or-later
\ No newline at end of file |