Skip to content

Commit e032be0

Browse files
authored
Merge pull request #48 from nextcloud/fix/noid/replacement-optional-for-hr-records
Make the replacement field optional and neutrally worded on HR-recorded absences
2 parents 7472e94 + 35c6851 commit e032be0

6 files changed

Lines changed: 163 additions & 26 deletions

File tree

SPECIFICATION.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,18 @@ HR** on an employee's behalf (§5.6) — employees don't self-record it.
368368
covers for them. The UI is a **user picker over the whole organisation**
369369
(`NcSelect` + core autocomplete), excluding the employee themselves. Submit is blocked
370370
until one is chosen. Sick leave (HR-recorded) needs none.
371+
372+
**The requirement is on the employee, not on the record.** It exists because somebody
373+
arranging their own leave knows who can cover and is asked to sort it out before
374+
going. When HR records or corrects an absence *for somebody else* (§5.5, §5.6) they
375+
are stating a fact, often after the event, and cannot nominate cover on that person's
376+
behalf — so there the field is offered but never demanded, and reads "Who is the
377+
replacement?" rather than "Who covers for you?". HR recording their *own* absence
378+
gets the self-service wording and requirement, since it is their leave.
379+
380+
Who may be named is *not* conditional: a replacement must be an employee (not a
381+
guest, §2.2) and not the person being covered for, whether or not the type demands
382+
one.
371383
4. On submit, backend:
372384
- Validates dates (`start ≤ end`, not entirely in the past — in the *employee's*
373385
timezone, §3.9 — unless HR, not

js/absence-main.mjs

Lines changed: 11 additions & 11 deletions
Large diffs are not rendered by default.

js/absence-main.mjs.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/Service/RequestService.php

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -112,19 +112,33 @@ private function withEmployeeLock(string $employeeUid, callable $fn): mixed {
112112
}
113113

114114
/**
115-
* Validate the nominated replacement for a leave type that requires one (§5.1).
116-
* Returns the (trimmed) replacement uid, or null when not required.
115+
* Validate the nominated replacement (§5.1). Returns the trimmed uid, or null
116+
* when none was given and none is demanded.
117117
*
118+
* @param bool $required whether an absent replacement is an error for a type that
119+
* demands one. True on the self-service paths; false when HR
120+
* records or corrects somebody else's absence.
118121
* @throws ValidationException
119122
*/
120-
private function resolveReplacement(string $employeeUid, LeaveType $type, ?string $replacementUid): ?string {
121-
if (!$type->getRequiresReplacement()) {
122-
return $replacementUid !== null && trim($replacementUid) !== '' ? trim($replacementUid) : null;
123-
}
124-
$replacementUid = trim((string)$replacementUid);
123+
private function resolveReplacement(string $employeeUid, LeaveType $type, ?string $replacementUid, bool $required = true): ?string {
124+
$replacementUid = $replacementUid !== null ? trim($replacementUid) : '';
125+
125126
if ($replacementUid === '') {
126-
throw new ValidationException('Please choose a replacement for this leave.');
127+
// The mandatory-replacement rule is about somebody applying for their own
128+
// leave: they know who can cover and are asked to arrange it before going.
129+
// HR recording or correcting an absence for somebody else is stating a fact,
130+
// often after the event, and cannot be expected to nominate cover on their
131+
// behalf — so there the field is offered but never demanded.
132+
if ($required && $type->getRequiresReplacement()) {
133+
throw new ValidationException('Please choose a replacement for this leave.');
134+
}
135+
return null;
127136
}
137+
138+
// Validated whether or not the type demands a replacement. Only the *demand*
139+
// is conditional; who may be named is not, and skipping these when the type
140+
// happened not to require one let a guest — or the employee themselves — be
141+
// recorded as covering.
128142
if ($replacementUid === $employeeUid) {
129143
throw new ValidationException('You cannot be your own replacement.');
130144
}
@@ -289,7 +303,8 @@ public function create(string $actorUid, array $data): LeaveRequest {
289303
$start = $this->normaliseDate((string)($data['startDate'] ?? ''));
290304
$end = $this->normaliseDate((string)($data['endDate'] ?? ''));
291305
$this->validateRange($actorUid, $start, $end, $type, (string)($data['reason'] ?? ''), (string)($data['attachmentNote'] ?? ''));
292-
$replacementUid = $this->resolveReplacement($employeeUid, $type, $data['replacementUid'] ?? null);
306+
// Not demanded when HR is recording for somebody else — see resolveReplacement().
307+
$replacementUid = $this->resolveReplacement($employeeUid, $type, $data['replacementUid'] ?? null, required: !$onBehalf);
293308

294309
// The employee enters the number of working days; the manager verifies it (§7).
295310
$workingDays = $this->normaliseWorkingDays($data['workingDays'] ?? null);
@@ -626,7 +641,7 @@ private function hrEdit(string $actorUid, LeaveRequest $request, array $data): L
626641
} catch (DoesNotExistException) {
627642
throw new ValidationException('This request refers to a leave type that no longer exists.');
628643
}
629-
$request->setReplacementUid($this->resolveReplacement($request->getEmployeeUid(), $type, $data['replacementUid']));
644+
$request->setReplacementUid($this->resolveReplacement($request->getEmployeeUid(), $type, $data['replacementUid'], required: false));
630645
}
631646
// HR may correct the working-day count (§5.5); otherwise it is kept as entered.
632647
if (array_key_exists('workingDays', $data) && $data['workingDays'] !== null) {

src/components/RequestDialog.vue

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@
5353

5454
<div v-if="needsReplacement" class="dialog__field">
5555
<label class="dialog__label">
56-
{{ t('absence', 'Replacement') }}<span class="dialog__req">*</span>
56+
{{ t('absence', 'Replacement') }}<span v-if="replacementRequired" class="dialog__req">*</span>
57+
<span v-else class="dialog__optional">{{ t('absence', '(optional)') }}</span>
5758
</label>
5859
<!-- eslint-disable @nextcloud/no-deprecated-library-props -- NcSelectUsers migration deferred: needs live-instance testing -->
5960
<NcSelect
@@ -63,12 +64,12 @@
6364
:userSelect="true"
6465
label="displayName"
6566
:filterable="false"
66-
:placeholder="t('absence', 'Who covers for you?')"
67+
:placeholder="replacementPlaceholder"
6768
:aria-label-combobox="t('absence', 'Replacement')"
6869
@search="onReplacementSearch" />
6970
<!-- eslint-enable @nextcloud/no-deprecated-library-props -->
7071
<p class="dialog__hint">
71-
{{ t('absence', 'A colleague who covers your duties while you are away. They are notified once your leave is approved.') }}
72+
{{ replacementHint }}
7273
</p>
7374
</div>
7475

@@ -270,6 +271,48 @@ export default {
270271
return this.selectedType ? this.selectedType.requiresReplacement : false
271272
},
272273
274+
/** Whether this dialog is about the signed-in user's own leave. */
275+
isOwnLeave() {
276+
return this.subjectUid === store.session.uid
277+
},
278+
279+
/**
280+
* Whose absence this is, for wording aimed at somebody else's record.
281+
* Empty until HR has picked an employee, which the strings below allow for.
282+
*/
283+
subjectName() {
284+
if (this.hrMode) {
285+
return this.selectedEmployee ? this.selectedEmployee.displayName : ''
286+
}
287+
return this.request ? (this.request.employeeName || this.request.employeeUid) : ''
288+
},
289+
290+
/**
291+
* Demanded only of somebody arranging their own leave: they know who can cover
292+
* and are asked to sort it out before going (§5.1). HR recording an absence for
293+
* somebody else is stating a fact, often after it happened, and cannot nominate
294+
* cover on their behalf — so the field is offered there but never required.
295+
* Mirrors the same rule in RequestService::resolveReplacement().
296+
*/
297+
replacementRequired() {
298+
return this.needsReplacement && this.isOwnLeave
299+
},
300+
301+
replacementPlaceholder() {
302+
return this.isOwnLeave
303+
? t('absence', 'Who covers for you?')
304+
: t('absence', 'Who is the replacement?')
305+
},
306+
307+
replacementHint() {
308+
if (this.isOwnLeave) {
309+
return t('absence', 'A colleague who covers your duties while you are away. They are notified once your leave is approved.')
310+
}
311+
return this.subjectName
312+
? t('absence', 'Optional. A colleague who covers for {name} while they are away, and is notified once this is recorded.', { name: this.subjectName })
313+
: t('absence', 'Optional. A colleague who covers these duties while this employee is away, and is notified once this is recorded.')
314+
},
315+
273316
// Bridge the native date pickers (Date objects) to our ISO string state.
274317
startDate: {
275318
get() {
@@ -405,7 +448,7 @@ export default {
405448
if (this.hrMode && !this.selectedEmployee) {
406449
return false
407450
}
408-
if (this.needsReplacement && !this.selectedReplacement) {
451+
if (this.replacementRequired && !this.selectedReplacement) {
409452
return false
410453
}
411454
if (this.requiresNote && this.reason.trim() === '') {

tests/Unit/Service/RequestServiceTest.php

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,73 @@ public function testAGuestCannotBeNominatedAsReplacement(): void {
172172
]);
173173
}
174174

175+
public function testApplyingForOwnLeaveStillDemandsAReplacement(): void {
176+
// §5.1 unchanged for self-service: the employee knows who can cover and is
177+
// asked to arrange it before going.
178+
$type = $this->type(1, true);
179+
$type->setRequiresReplacement(true);
180+
$this->leaveTypeMapper->method('find')->with(1)->willReturn($type);
181+
$this->permission->method('isHr')->with('emp')->willReturn(false);
182+
183+
$this->requestMapper->expects(self::never())->method('insert');
184+
185+
$start = date('Y-m-d', strtotime('+30 days'));
186+
$this->expectException(ValidationException::class);
187+
$this->service->create('emp', [
188+
'typeId' => 1,
189+
'startDate' => $start,
190+
'endDate' => $start,
191+
'workingDays' => 1.0,
192+
]);
193+
}
194+
195+
public function testHrRecordingForSomeoneElseNeedsNoReplacement(): void {
196+
// HR is stating a fact about somebody else's absence, often after it happened,
197+
// and cannot nominate cover on their behalf — so the type's requirement does
198+
// not apply to them.
199+
$type = $this->type(1, true);
200+
$type->setRequiresReplacement(true);
201+
$this->leaveTypeMapper->method('find')->with(1)->willReturn($type);
202+
$this->permission->method('isHr')->with('hr')->willReturn(true);
203+
$this->requestMapper->method('findOverlapping')->willReturn([]);
204+
$this->requestMapper->method('insert')->willReturnArgument(0);
205+
206+
$start = date('Y-m-d', strtotime('+30 days'));
207+
$created = $this->service->create('hr', [
208+
'typeId' => 1,
209+
'employeeUid' => 'emp',
210+
'startDate' => $start,
211+
'endDate' => $start,
212+
'workingDays' => 1.0,
213+
]);
214+
215+
self::assertNull($created->getReplacementUid());
216+
self::assertSame('emp', $created->getEmployeeUid());
217+
}
218+
219+
public function testAReplacementIsValidatedEvenWhenTheTypeDoesNotDemandOne(): void {
220+
// Only the *demand* is conditional. Skipping the checks when the type happened
221+
// not to require a replacement let a guest be recorded as covering.
222+
$this->guestUids = ['ext'];
223+
$this->userManager->method('userExists')->willReturn(true);
224+
$type = $this->type(1, true);
225+
$type->setRequiresReplacement(false);
226+
$this->leaveTypeMapper->method('find')->with(1)->willReturn($type);
227+
$this->permission->method('isHr')->with('emp')->willReturn(false);
228+
229+
$this->requestMapper->expects(self::never())->method('insert');
230+
231+
$start = date('Y-m-d', strtotime('+30 days'));
232+
$this->expectException(ValidationException::class);
233+
$this->service->create('emp', [
234+
'typeId' => 1,
235+
'startDate' => $start,
236+
'endDate' => $start,
237+
'workingDays' => 1.0,
238+
'replacementUid' => 'ext',
239+
]);
240+
}
241+
175242
public function testEmployeeCannotReclassifyIntoHrOnlyType(): void {
176243
$request = $this->pendingOwnRequest();
177244
$this->requestMapper->method('find')->with(5)->willReturn($request);

0 commit comments

Comments
 (0)