mirror of https://github.com/nextcloud/server.git
feat(dav): implement personal absence settings
Signed-off-by: Richard Steinmetz <richard@steinmetz.cloud>pull/40767/head
parent
3e6642ab0b
commit
425e770c04
@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* @copyright Copyright (c) 2023 Richard Steinmetz <richard@steinmetz.cloud>
|
||||
*
|
||||
* @author Richard Steinmetz <richard@steinmetz.cloud>
|
||||
*
|
||||
* @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 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
namespace OCA\DAV\Controller;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use OCA\DAV\AppInfo\Application;
|
||||
use OCA\DAV\Service\AbsenceService;
|
||||
use OCP\AppFramework\Controller;
|
||||
use OCP\AppFramework\Http;
|
||||
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
|
||||
use OCP\AppFramework\Http\JSONResponse;
|
||||
use OCP\AppFramework\Http\Response;
|
||||
use OCP\IRequest;
|
||||
|
||||
class AvailabilitySettingsController extends Controller {
|
||||
public function __construct(
|
||||
IRequest $request,
|
||||
private ?string $userId,
|
||||
private AbsenceService $absenceService,
|
||||
) {
|
||||
parent::__construct(Application::APP_ID, $request);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \OCP\DB\Exception
|
||||
* @throws \Exception
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function updateAbsence(
|
||||
string $firstDay,
|
||||
string $lastDay,
|
||||
string $status,
|
||||
string $message,
|
||||
): Response {
|
||||
$userId = $this->userId;
|
||||
if ($userId === null) {
|
||||
return new JSONResponse([], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
$parsedFirstDay = new DateTimeImmutable($firstDay);
|
||||
$parsedLastDay = new DateTimeImmutable($lastDay);
|
||||
if ($parsedFirstDay->getTimestamp() >= $parsedLastDay->getTimestamp()) {
|
||||
throw new \Exception('First day is on or after last day');
|
||||
}
|
||||
|
||||
$absence = $this->absenceService->createOrUpdateAbsence(
|
||||
$userId,
|
||||
$firstDay,
|
||||
$lastDay,
|
||||
$status,
|
||||
$message,
|
||||
);
|
||||
return new JSONResponse($absence);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \OCP\DB\Exception
|
||||
*/
|
||||
#[NoAdminRequired]
|
||||
public function clearAbsence(): Response {
|
||||
$userId = $this->userId;
|
||||
if ($userId === null) {
|
||||
return new JSONResponse([], Http::STATUS_FORBIDDEN);
|
||||
}
|
||||
|
||||
$this->absenceService->clearAbsence($userId);
|
||||
return new JSONResponse([]);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,160 @@
|
||||
<!--
|
||||
- @copyright Copyright (c) 2023 Richard Steinmetz <richard@steinmetz.cloud>
|
||||
-
|
||||
- @author Richard Steinmetz <richard@steinmetz.cloud>
|
||||
-
|
||||
- @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 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 General Public License for more details.
|
||||
-
|
||||
- You should have received a copy of the GNU General Public License
|
||||
- along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
-
|
||||
-->
|
||||
|
||||
<template>
|
||||
<div class="absence">
|
||||
<div class="absence__dates">
|
||||
<NcDateTimePickerNative id="absence-first-day"
|
||||
v-model="firstDay"
|
||||
:label="$t('dav', 'First day')"
|
||||
class="absence__dates__picker" />
|
||||
<NcDateTimePickerNative id="absence-last-day"
|
||||
v-model="lastDay"
|
||||
:label="$t('dav', 'Last day (inclusive)')"
|
||||
class="absence__dates__picker" />
|
||||
</div>
|
||||
<NcTextField :value.sync="status" :label="$t('dav', 'Short absence status')" />
|
||||
<NcTextArea :value.sync="message" :label="$t('dav', 'Long absence Message')" />
|
||||
|
||||
<div class="absence__buttons">
|
||||
<NcButton :disabled="loading || !valid"
|
||||
type="primary"
|
||||
@click="saveForm">
|
||||
{{ $t('dav', 'Save') }}
|
||||
</NcButton>
|
||||
<NcButton :disabled="loading || !valid"
|
||||
type="error"
|
||||
@click="clearAbsence">
|
||||
{{ $t('dav', 'Disable absence') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
|
||||
import NcTextField from '@nextcloud/vue/dist/Components/NcTextField.js'
|
||||
import NcTextArea from '@nextcloud/vue/dist/Components/NcTextArea.js'
|
||||
import NcDateTimePickerNative from '@nextcloud/vue/dist/Components/NcDateTimePickerNative.js'
|
||||
import { generateUrl } from '@nextcloud/router'
|
||||
import axios from '@nextcloud/axios'
|
||||
import { formatDateAsYMD } from '../utils/date.js'
|
||||
import { loadState } from '@nextcloud/initial-state'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
|
||||
export default {
|
||||
name: 'AbsenceForm',
|
||||
components: {
|
||||
NcButton,
|
||||
NcTextField,
|
||||
NcTextArea,
|
||||
NcDateTimePickerNative,
|
||||
},
|
||||
data() {
|
||||
const { firstDay, lastDay, status, message } = loadState('dav', 'absence', {})
|
||||
|
||||
return {
|
||||
loading: false,
|
||||
status: status ?? '',
|
||||
message: message ?? '',
|
||||
firstDay: firstDay ? new Date(firstDay) : new Date(),
|
||||
lastDay: lastDay ? new Date(lastDay) : null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
/**
|
||||
* @return {boolean}
|
||||
*/
|
||||
valid() {
|
||||
return !!this.firstDay
|
||||
&& !!this.lastDay
|
||||
&& !!this.status
|
||||
&& this.lastDay > this.firstDay
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
resetForm() {
|
||||
this.status = ''
|
||||
this.message = ''
|
||||
this.firstDay = new Date()
|
||||
this.lastDay = null
|
||||
},
|
||||
async saveForm() {
|
||||
if (!this.valid) {
|
||||
return
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
try {
|
||||
await axios.post(generateUrl('/apps/dav/settings/absence'), {
|
||||
firstDay: formatDateAsYMD(this.firstDay),
|
||||
lastDay: formatDateAsYMD(this.lastDay),
|
||||
status: this.status,
|
||||
message: this.message,
|
||||
})
|
||||
} catch (error) {
|
||||
showError(this.$t('dav', 'Failed to save your absence settings'))
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
async clearAbsence() {
|
||||
this.loading = true
|
||||
try {
|
||||
await axios.delete(generateUrl('/apps/dav/settings/absence'))
|
||||
this.resetForm()
|
||||
} catch (error) {
|
||||
showError(this.$t('dav', 'Failed to clear your absence settings'))
|
||||
} 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;
|
||||
|
||||
::v-deep .native-datetime-picker--input {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__buttons {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -0,0 +1,205 @@
|
||||
<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-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>
|
||||
</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/dist/Components/NcButton.js'
|
||||
import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
|
||||
import NcTimezonePicker from '@nextcloud/vue/dist/Components/NcTimezonePicker.js'
|
||||
|
||||
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: 32px 12px 12px 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&__heading {
|
||||
margin-right: 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-right: 12px;
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
color: var(--color-text-lighter);
|
||||
margin-top: 4px;
|
||||
align-self: center;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @copyright Copyright (c) 2023 Richard Steinmetz <richard@steinmetz.cloud>
|
||||
*
|
||||
* @author Richard Steinmetz <richard@steinmetz.cloud>
|
||||
*
|
||||
* @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 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 General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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.getUTCFullYear()
|
||||
const month = (date.getUTCMonth() + 1).toString().padStart(2, '0')
|
||||
const day = date.getUTCDate().toString().padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue