Merge pull request #45766 from nextcloud/feat/ooo-replacement

Feat: Allow users to select another user as their out-of-office replacement
pull/46215/head
Daniel 2024-07-01 23:25:43 +07:00 committed by GitHub
commit 92acbb0d39
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 321 additions and 12 deletions

@ -10,7 +10,7 @@
<name>WebDAV</name>
<summary>WebDAV endpoint</summary>
<description>WebDAV endpoint</description>
<version>1.31.0</version>
<version>1.31.1</version>
<licence>agpl</licence>
<author>owncloud.org</author>
<namespace>DAV</namespace>

@ -326,6 +326,7 @@ return array(
'OCA\\DAV\\Migration\\Version1029Date20221114151721' => $baseDir . '/../lib/Migration/Version1029Date20221114151721.php',
'OCA\\DAV\\Migration\\Version1029Date20231004091403' => $baseDir . '/../lib/Migration/Version1029Date20231004091403.php',
'OCA\\DAV\\Migration\\Version1030Date20240205103243' => $baseDir . '/../lib/Migration/Version1030Date20240205103243.php',
'OCA\\DAV\\Migration\\Version1031Date20240610134258' => $baseDir . '/../lib/Migration/Version1031Date20240610134258.php',
'OCA\\DAV\\Profiler\\ProfilerPlugin' => $baseDir . '/../lib/Profiler/ProfilerPlugin.php',
'OCA\\DAV\\Provisioning\\Apple\\AppleProvisioningNode' => $baseDir . '/../lib/Provisioning/Apple/AppleProvisioningNode.php',
'OCA\\DAV\\Provisioning\\Apple\\AppleProvisioningPlugin' => $baseDir . '/../lib/Provisioning/Apple/AppleProvisioningPlugin.php',

@ -341,6 +341,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Migration\\Version1029Date20221114151721' => __DIR__ . '/..' . '/../lib/Migration/Version1029Date20221114151721.php',
'OCA\\DAV\\Migration\\Version1029Date20231004091403' => __DIR__ . '/..' . '/../lib/Migration/Version1029Date20231004091403.php',
'OCA\\DAV\\Migration\\Version1030Date20240205103243' => __DIR__ . '/..' . '/../lib/Migration/Version1030Date20240205103243.php',
'OCA\\DAV\\Migration\\Version1031Date20240610134258' => __DIR__ . '/..' . '/../lib/Migration/Version1031Date20240610134258.php',
'OCA\\DAV\\Profiler\\ProfilerPlugin' => __DIR__ . '/..' . '/../lib/Profiler/ProfilerPlugin.php',
'OCA\\DAV\\Provisioning\\Apple\\AppleProvisioningNode' => __DIR__ . '/..' . '/../lib/Provisioning/Apple/AppleProvisioningNode.php',
'OCA\\DAV\\Provisioning\\Apple\\AppleProvisioningPlugin' => __DIR__ . '/..' . '/../lib/Provisioning/Apple/AppleProvisioningPlugin.php',

@ -93,6 +93,8 @@ class OutOfOfficeController extends OCSController {
'lastDay' => $data->getLastDay(),
'status' => $data->getStatus(),
'message' => $data->getMessage(),
'replacementUserId' => $data->getReplacementUserId(),
'replacementUserDisplayName' => $data->getReplacementUserDisplayName(),
]);
}
@ -103,11 +105,14 @@ class OutOfOfficeController extends OCSController {
* @param string $lastDay Last day of the absence in format `YYYY-MM-DD`
* @param string $status Short text that is set as user status during the absence
* @param string $message Longer multiline message that is shown to others during the absence
* @return DataResponse<Http::STATUS_OK, DAVOutOfOfficeData, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'firstDay'}, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, null, array{}>
* @param string $replacementUserId User id of the replacement user
* @param string $replacementUserDisplayName Display name of the replacement user
* @return DataResponse<Http::STATUS_OK, DAVOutOfOfficeData, array{}>|DataResponse<Http::STATUS_BAD_REQUEST, array{error: 'firstDay'}, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, null, array{}>|DataResponse<Http::STATUS_NOT_FOUND, null, array{}>
*
* 200: Absence data
* 400: When the first day is not before the last day
* 401: When the user is not logged in
* 404: When the replacementUserId was provided but replacement user was not found
*/
#[NoAdminRequired]
public function setOutOfOffice(
@ -115,12 +120,22 @@ class OutOfOfficeController extends OCSController {
string $lastDay,
string $status,
string $message,
string $replacementUserId = '',
string $replacementUserDisplayName = ''
): DataResponse {
$user = $this->userSession?->getUser();
if ($user === null) {
return new DataResponse(null, Http::STATUS_UNAUTHORIZED);
}
if ($replacementUserId !== '') {
$replacementUser = $this->userManager->get($replacementUserId);
if ($replacementUser === null) {
return new DataResponse(null, Http::STATUS_NOT_FOUND);
}
}
$parsedFirstDay = new DateTimeImmutable($firstDay);
$parsedLastDay = new DateTimeImmutable($lastDay);
if ($parsedFirstDay->getTimestamp() > $parsedLastDay->getTimestamp()) {
@ -133,6 +148,8 @@ class OutOfOfficeController extends OCSController {
$lastDay,
$status,
$message,
$replacementUserId,
$replacementUserDisplayName
);
$this->coordinator->clearCache($user->getUID());
@ -143,6 +160,8 @@ class OutOfOfficeController extends OCSController {
'lastDay' => $data->getLastDay(),
'status' => $data->getStatus(),
'message' => $data->getMessage(),
'replacementUserId' => $data->getReplacementUserId(),
'replacementUserDisplayName' => $data->getReplacementUserDisplayName(),
]);
}

@ -29,6 +29,10 @@ use OCP\User\IOutOfOfficeData;
* @method void setStatus(string $status)
* @method string getMessage()
* @method void setMessage(string $message)
* @method string getReplacementUserId()
* @method void setReplacementUserId(string $replacementUserId)
* @method string getReplacementUserDisplayName()
* @method void setReplacementUserDisplayName(string $replacementUserDisplayName)
*/
class Absence extends Entity implements JsonSerializable {
protected string $userId = '';
@ -43,12 +47,18 @@ class Absence extends Entity implements JsonSerializable {
protected string $message = '';
protected string $replacementUserId = '';
protected string $replacementUserDisplayName = '';
public function __construct() {
$this->addType('userId', 'string');
$this->addType('firstDay', 'string');
$this->addType('lastDay', 'string');
$this->addType('status', 'string');
$this->addType('message', 'string');
$this->addType('replacementUserId', 'string');
$this->addType('replacementUserDisplayName', 'string');
}
public function toOutOufOfficeData(IUser $user, string $timezone): IOutOfOfficeData {
@ -70,6 +80,8 @@ class Absence extends Entity implements JsonSerializable {
$endDate->getTimestamp(),
$this->getStatus(),
$this->getMessage(),
$this->getReplacementUserId(),
$this->getReplacementUserDisplayName(),
);
}
@ -80,6 +92,8 @@ class Absence extends Entity implements JsonSerializable {
'lastDay' => $this->lastDay,
'status' => $this->status,
'message' => $this->message,
'replacementUserId' => $this->replacementUserId,
'replacementUserDisplayName' => $this->replacementUserDisplayName,
];
}
}

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version1031Date20240610134258 extends SimpleMigrationStep {
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$tableDavAbsence = $schema->getTable('dav_absence');
if (!$tableDavAbsence->hasColumn('replacement_user_id')) {
$tableDavAbsence->addColumn('replacement_user_id', Types::STRING, [
'notnull' => false,
'default' => '',
'length' => 64,
]);
}
if (!$tableDavAbsence->hasColumn('replacement_user_display_name')) {
$tableDavAbsence->addColumn('replacement_user_display_name', Types::STRING, [
'notnull' => false,
'default' => '',
'length' => 64,
]);
}
return $schema;
}
}

@ -13,6 +13,8 @@ namespace OCA\DAV;
* @psalm-type DAVOutOfOfficeDataCommon = array{
* userId: string,
* message: string,
* replacementUserId: string,
* replacementUserDisplayName: string,
* }
*
* @psalm-type DAVOutOfOfficeData = DAVOutOfOfficeDataCommon&array{

@ -47,6 +47,8 @@ class AbsenceService {
string $lastDay,
string $status,
string $message,
?string $replacementUserId = null,
?string $replacementUserDisplayName = null,
): Absence {
try {
$absence = $this->absenceMapper->findByUserId($user->getUID());
@ -59,6 +61,8 @@ class AbsenceService {
$absence->setLastDay($lastDay);
$absence->setStatus($status);
$absence->setMessage($message);
$absence->setReplacementUserId($replacementUserId ?? '');
$absence->setReplacementUserDisplayName($replacementUserDisplayName ?? '');
if ($absence->getId() === null) {
$absence = $this->absenceMapper->insert($absence);

@ -133,7 +133,9 @@
"type": "object",
"required": [
"userId",
"message"
"message",
"replacementUserId",
"replacementUserDisplayName"
],
"properties": {
"userId": {
@ -141,6 +143,12 @@
},
"message": {
"type": "string"
},
"replacementUserId": {
"type": "string"
},
"replacementUserDisplayName": {
"type": "string"
}
}
}
@ -570,6 +578,24 @@
"type": "string"
}
},
{
"name": "replacementUserId",
"in": "query",
"description": "User id of the replacement user",
"schema": {
"type": "string",
"default": ""
}
},
{
"name": "replacementUserDisplayName",
"in": "query",
"description": "Display name of the replacement user",
"schema": {
"type": "string",
"default": ""
}
},
{
"name": "userId",
"in": "path",
@ -690,6 +716,36 @@
}
}
}
},
"404": {
"description": "When the replacementUserId was provided but replacement user was not found",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"nullable": true
}
}
}
}
}
}
}
}
}
},

@ -17,6 +17,21 @@
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" />
@ -39,13 +54,16 @@
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 NcSelect from '@nextcloud/vue/dist/Components/NcSelect.js'
import NcDateTimePickerNative from '@nextcloud/vue/dist/Components/NcDateTimePickerNative.js'
import { generateOcsUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
import debounce from 'debounce'
import axios from '@nextcloud/axios'
import { formatDateAsYMD } from '../utils/date.js'
import { loadState } from '@nextcloud/initial-state'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { Type as ShareTypes } from '@nextcloud/sharing'
import logger from '../service/logger.js'
@ -56,16 +74,20 @@ export default {
NcTextField,
NcTextArea,
NcDateTimePickerNative,
NcSelect
},
data() {
const { firstDay, lastDay, status, message } = loadState('dav', 'absence', {})
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: replacementUserId ,
replacementUser: replacementUserId ? { user: replacementUserId, displayName: replacementUserDisplayName } : null,
searchLoading: false,
options: [],
}
},
computed: {
@ -93,6 +115,99 @@ export default {
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 = [
ShareTypes.SHARE_TYPE_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
@ -105,6 +220,8 @@ export default {
lastDay: formatDateAsYMD(this.lastDay),
status: this.status,
message: this.message,
replacementUserId: this.replacementUser?.user ?? null,
replacementUserDisplayName: this.replacementUser?.displayName ?? null,
})
showSuccess(this.$t('dav', 'Absence saved'))
} catch (error) {

File diff suppressed because one or more lines are too long

@ -13,6 +13,7 @@ SPDX-FileCopyrightText: jden <jason@denizac.org>
SPDX-FileCopyrightText: inherits developers
SPDX-FileCopyrightText: escape-html developers
SPDX-FileCopyrightText: defunctzombie
SPDX-FileCopyrightText: debounce developers
SPDX-FileCopyrightText: atomiks
SPDX-FileCopyrightText: assert developers
SPDX-FileCopyrightText: Varun A P
@ -110,6 +111,9 @@ This file is generated from multiple sources. Included packages:
- @nextcloud/router
- version: 3.0.1
- license: GPL-3.0-or-later
- @nextcloud/sharing
- version: 0.1.0
- license: GPL-3.0-or-later
- @nextcloud/vue-select
- version: 3.25.0
- license: MIT
@ -179,6 +183,9 @@ This file is generated from multiple sources. Included packages:
- css-loader
- version: 6.10.0
- license: MIT
- debounce
- version: 2.1.0
- license: MIT
- define-data-property
- version: 1.1.4
- license: MIT

File diff suppressed because one or more lines are too long

@ -61,6 +61,8 @@ class AvailabilityCoordinator implements IAvailabilityCoordinator {
$cachedData['endDate'],
$cachedData['shortMessage'],
$cachedData['message'],
$cachedData['replacementUserId'],
$cachedData['replacementUserDisplayName'],
);
}
@ -72,6 +74,8 @@ class AvailabilityCoordinator implements IAvailabilityCoordinator {
'endDate' => $data->getEndDate(),
'shortMessage' => $data->getShortMessage(),
'message' => $data->getMessage(),
'replacementUserId' => $data->getReplacementUserId(),
'replacementUserDisplayName' => $data->getReplacementUserDisplayName(),
], JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
$this->logger->error('Failed to serialize out-of-office data: ' . $e->getMessage(), [

@ -18,7 +18,9 @@ class OutOfOfficeData implements IOutOfOfficeData {
private int $startDate,
private int $endDate,
private string $shortMessage,
private string $message) {
private string $message,
private string $replacementUserId,
private string $replacementUserDisplayName) {
}
public function getId(): string {
@ -45,6 +47,14 @@ class OutOfOfficeData implements IOutOfOfficeData {
return $this->message;
}
public function getReplacementUserId(): string {
return $this->replacementUserId;
}
public function getReplacementUserDisplayName(): string {
return $this->replacementUserDisplayName;
}
public function jsonSerialize(): array {
return [
'id' => $this->getId(),
@ -53,6 +63,8 @@ class OutOfOfficeData implements IOutOfOfficeData {
'endDate' => $this->getEndDate(),
'shortMessage' => $this->getShortMessage(),
'message' => $this->getMessage(),
'replacementUserId' => $this->getReplacementUserId(),
'replacementUserDisplayName' => $this->getReplacementUserDisplayName(),
];
}
}

@ -22,6 +22,8 @@ use OCP\IUser;
* endDate: int,
* shortMessage: string,
* message: string,
* replacementUserId: string,
* replacementUserDisplayName: string
* }
*
* @since 28.0.0
@ -69,6 +71,20 @@ interface IOutOfOfficeData extends JsonSerializable {
*/
public function getMessage(): string;
/**
* Get the replacement user id for auto responders and similar
*
* @since 30.0.0
*/
public function getReplacementUserId(): string;
/**
* Get the replacement user displayName for auto responders and similar
*
* @since 30.0.0
*/
public function getReplacementUserDisplayName(): string;
/**
* @return OutOfOfficeData
*

@ -73,6 +73,8 @@ class AvailabilityCoordinatorTest extends TestCase {
$absence->setLastDay('2023-10-08');
$absence->setStatus('Vacation');
$absence->setMessage('On vacation');
$absence->setReplacementUserId('batman');
$absence->setReplacementUserDisplayName('Bruce Wayne');
$this->timezoneService->method('getUserTimezone')->with('user')->willReturn('Europe/Berlin');
$user = $this->createMock(IUser::class);
@ -89,7 +91,7 @@ class AvailabilityCoordinatorTest extends TestCase {
$this->cache->expects(self::exactly(2))
->method('set')
->withConsecutive([$user->getUID() . '_timezone', 'Europe/Berlin', 3600],
[$user->getUID(), '{"id":"420","startDate":1696111200,"endDate":1696802340,"shortMessage":"Vacation","message":"On vacation"}', 300]);
[$user->getUID(), '{"id":"420","startDate":1696111200,"endDate":1696802340,"shortMessage":"Vacation","message":"On vacation","replacementUserId":"batman","replacementUserDisplayName":"Bruce Wayne"}', 300]);
$expected = new OutOfOfficeData(
'420',
@ -98,6 +100,8 @@ class AvailabilityCoordinatorTest extends TestCase {
1696802340,
'Vacation',
'On vacation',
'batman',
'Bruce Wayne',
);
$actual = $this->availabilityCoordinator->getCurrentOutOfOfficeData($user);
self::assertEquals($expected, $actual);
@ -111,6 +115,8 @@ class AvailabilityCoordinatorTest extends TestCase {
$absence->setLastDay('2023-10-08');
$absence->setStatus('Vacation');
$absence->setMessage('On vacation');
$absence->setReplacementUserId('batman');
$absence->setReplacementUserDisplayName('Bruce Wayne');
$user = $this->createMock(IUser::class);
$user->method('getUID')
@ -118,7 +124,7 @@ class AvailabilityCoordinatorTest extends TestCase {
$this->cache->expects(self::exactly(2))
->method('get')
->willReturnOnConsecutiveCalls('UTC', '{"id":"420","startDate":1696118400,"endDate":1696809540,"shortMessage":"Vacation","message":"On vacation"}');
->willReturnOnConsecutiveCalls('UTC', '{"id":"420","startDate":1696118400,"endDate":1696809540,"shortMessage":"Vacation","message":"On vacation","replacementUserId":"batman","replacementUserDisplayName":"Bruce Wayne"}');
$this->absenceService->expects(self::never())
->method('getAbsence');
$this->cache->expects(self::exactly(1))
@ -131,6 +137,8 @@ class AvailabilityCoordinatorTest extends TestCase {
1696809540,
'Vacation',
'On vacation',
'batman',
'Bruce Wayne'
);
$actual = $this->availabilityCoordinator->getCurrentOutOfOfficeData($user);
self::assertEquals($expected, $actual);
@ -170,6 +178,8 @@ class AvailabilityCoordinatorTest extends TestCase {
$absence->setLastDay('2023-10-08');
$absence->setStatus('Vacation');
$absence->setMessage('On vacation');
$absence->setReplacementUserId('batman');
$absence->setReplacementUserDisplayName('Bruce Wayne');
$this->timezoneService->method('getUserTimezone')->with('user')->willReturn('Europe/Berlin');
$user = $this->createMock(IUser::class);
@ -185,7 +195,7 @@ class AvailabilityCoordinatorTest extends TestCase {
->willReturn($absence);
$this->cache->expects(self::once())
->method('set')
->with('user', '{"id":"420","startDate":1696118400,"endDate":1696809540,"shortMessage":"Vacation","message":"On vacation"}', 300);
->with('user', '{"id":"420","startDate":1696118400,"endDate":1696809540,"shortMessage":"Vacation","message":"On vacation","replacementUserId":"batman","replacementUserDisplayName":"Bruce Wayne"}', 300);
$expected = new OutOfOfficeData(
'420',
@ -194,6 +204,8 @@ class AvailabilityCoordinatorTest extends TestCase {
1696809540,
'Vacation',
'On vacation',
'batman',
'Bruce Wayne'
);
$actual = $this->availabilityCoordinator->getCurrentOutOfOfficeData($user);
self::assertEquals($expected, $actual);