Skip to content

Commit

Permalink
Time entry feedback (#195)
Browse files Browse the repository at this point in the history
* Added min validator to hours

* When resetting time don't clear the date

* Added `distinctUntilChanged` to staffId observable to prevent refreshing timesheet when token is renewed

* Trigger blur on enter press of client and project dropdowns

* Update clientId and projectId on blur

* Refactored time entry component to use explicit blur events to trigger event emitter to prevent duplicate requests and conflicts with observable streams based on valueChanges
  • Loading branch information
rmaffitsancsoft authored Jul 2, 2024
1 parent d84b7e1 commit 79389b2
Show file tree
Hide file tree
Showing 6 changed files with 87 additions and 54 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@
class="w-full rounded mb-5 border-none outline-none bg-gray-900 p-1"
type="text"
#promptString
>
</textarea>
></textarea>
</div>
<div class="text-right">
<button
Expand Down
4 changes: 2 additions & 2 deletions src/angular/hq/src/app/common/toast/toast.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@
@if (toasts.length > 0) {
<div class="w-full h-full flex flex-col-reverse p-2">
@for (toast of toasts; track toast.timestamp) {
<div class="w-full text-sm mt-2">
<div class="w-full text-sm mt-2 border border-gray-950">
<div class="bg-blue-900 py-1 px-2 font-bold">
{{ toast.title }}
</div>
<div class="bg-gray-800 alt py-1 px-2">
<div class="bg-gray-700 alt p-2">
{{ toast.message }}
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
Subject,
combineLatest,
debounceTime,
distinctUntilChanged,
map,
merge,
shareReplay,
Expand Down Expand Up @@ -56,6 +57,7 @@ export class StaffDashboardService {
const staffId$ = oidcSecurityService.userData$.pipe(
map((t) => t.userData),
map((t) => t.staff_id as string),
distinctUntilChanged(),
);

const search$ = this.search.valueChanges.pipe(startWith(this.search.value));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@
class="w-full bg-blue-900 border border-black rounded py-1 px-2 mr-2 text-right appearance-none"
type="number"
formControlName="hours"
#hoursInput
step="0.25"
(keydown.enter)="blurInput($event.target)"
(blur)="save()"
(keydown.enter)="onEnter($event.target)"
/>
</td>
<td class="border-b border-black py-2 pl-2">
Expand Down Expand Up @@ -59,6 +61,8 @@
form.controls.clientId.errors && form.touched,
'border-black': !form.controls.clientId.errors || !form.touched,
}"
(blur)="save()"
(keydown.enter)="onEnter($event.target)"
class="w-full pl-2 pr-[43px] appearance-none focus:outline-none hover:cursor-pointer font-medium row-start-1 col-start-1 border focus:border-white text-gray-100 bg-blue-900 rounded h-[30px]"
formControlName="clientId"
>
Expand Down Expand Up @@ -88,6 +92,8 @@
form.controls.projectId.errors && form.touched,
'border-black': !form.controls.projectId.errors || !form.touched,
}"
(blur)="save()"
(keydown.enter)="onEnter($event.target)"
class="w-full pl-2 pr-[43px] appearance-none focus:outline-none hover:cursor-pointer font-medium row-start-1 col-start-1 border focus:border-white text-gray-100 bg-blue-900 rounded h-[30px]"
formControlName="projectId"
>
Expand Down Expand Up @@ -118,6 +124,8 @@
!form.controls.activityId.errors || !form.touched,
}"
class="w-full pl-2 pr-[43px] appearance-none focus:outline-none hover:cursor-pointer font-medium row-start-1 col-start-1 border focus:border-white text-gray-100 bg-blue-900 rounded h-[30px]"
(blur)="save()"
(keydown.enter)="onEnter($event.target)"
formControlName="activityId"
>
<option [ngValue]="null" class="text-gray-50">Activity</option>
Expand All @@ -136,7 +144,8 @@
class="w-[100%] block text-[14px] px-2 py-1 border border-black appearance-none focus:outline-none focus:border-white placeholder:text-gray-100 bg-blue-900 rounded h-[30px]"
type="text"
formControlName="task"
(keydown.enter)="blurInput($event.target)"
(blur)="save()"
(keydown.enter)="onEnter($event.target)"
/>
}
}
Expand All @@ -152,21 +161,22 @@
class="w-[100%] block text-[14px] px-2 py-1 border border-black appearance-none focus:outline-none bg-blue-900 rounded h-[30px]"
type="text"
formControlName="notes"
(keydown.enter)="blurInput($event.target)"
(blur)="save()"
(keydown.enter)="onEnter($event.target)"
/>
</td>
<td class="border-b border-black py-2 pl-2 pr-5">
@if (form.controls.id.value) {
<button
type="submit"
type="button"
(click)="deleteTime()"
class="h-[30px] px-2 py-2 rounded w-full"
>
<i class="bi bi-trash text-red-600 hover:text-red-700"></i>
</button>
} @else {
<button
type="submit"
type="button"
(click)="resetTime()"
class="h-[30px] px-2 py-2 rounded w-full"
>
Expand All @@ -188,8 +198,10 @@
class="w-full bg-blue-900 border border-black rounded py-1 px-2 mr-2 text-right appearance-none"
type="number"
formControlName="hours"
#hoursInput
step="0.25"
(keydown.enter)="blurInput($event.target)"
(blur)="save()"
(keydown.enter)="onEnter($event.target)"
/>
</td>
<td class="border-b border-black py-2 pl-2">
Expand Down Expand Up @@ -222,6 +234,8 @@
}"
class="w-full pl-2 pr-[43px] appearance-none focus:outline-none hover:cursor-pointer font-medium row-start-1 col-start-1 border focus:border-white text-gray-100 bg-blue-900 rounded h-[30px]"
formControlName="clientId"
(blur)="save()"
(keydown.enter)="onEnter($event.target)"
>
<option [ngValue]="null" class="text-gray-50">Client</option>
<!-- <option [ngValue]="form.controls.clientId.value">
Expand Down Expand Up @@ -251,6 +265,8 @@
}"
class="w-full pl-2 pr-[43px] appearance-none focus:outline-none hover:cursor-pointer font-medium row-start-1 col-start-1 border focus:border-white text-gray-100 bg-blue-900 rounded h-[30px]"
formControlName="projectId"
(blur)="save()"
(keydown.enter)="onEnter($event.target)"
>
<option [ngValue]="null" class="text-gray-50">Project</option>
<!-- <option [ngValue]="form.controls.projectId.value">
Expand Down Expand Up @@ -280,6 +296,8 @@
}"
class="w-full pl-2 pr-[43px] appearance-none focus:outline-none hover:cursor-pointer font-medium row-start-1 col-start-1 border focus:border-white text-gray-100 bg-blue-900 rounded h-[30px]"
formControlName="activityId"
(blur)="save()"
(keydown.enter)="onEnter($event.target)"
>
<option [ngValue]="null" class="text-gray-50">Activity</option>
@for (activity of activities$ | async; track activity.id) {
Expand All @@ -297,7 +315,8 @@
class="w-[100%] block text-[14px] px-2 py-1 border border-black appearance-none focus:outline-none focus:border-white placeholder:text-gray-100 bg-blue-900 rounded h-[30px]"
type="text"
formControlName="task"
(keydown.enter)="blurInput($event.target)"
(blur)="save()"
(keydown.enter)="onEnter($event.target)"
/>
}
}
Expand All @@ -313,16 +332,19 @@
class="w-[100%] block text-[14px] px-2 py-1 border border-black appearance-none focus:outline-none bg-blue-900 rounded h-[30px]"
type="text"
formControlName="notes"
(keydown.enter)="blurInput($event.target)"
(blur)="save()"
(keydown.enter)="onEnter($event.target)"
/>
</td>
<td class="border-b border-black py-2 pl-2 pr-5">
<td class="border-b border-black py-2 pl-2 pr-5 text-center">
@if (time?.rejectionNotes) {
<div>
<i
[title]="time?.rejectionNotes"
class="bi bi-exclamation-diamond-fill text-yellow-550"
></i>
<button type="button" (click)="showRejectionNotes()">
<i
[title]="time?.rejectionNotes"
class="bi bi-exclamation-diamond-fill text-yellow-550"
></i>
</button>
</div>
}
</td>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import {
Component,
ElementRef,
EventEmitter,
HostBinding,
Input,
OnChanges,
OnDestroy,
Output,
SimpleChanges,
ViewChild,
} from '@angular/core';
import {
GetDashboardTimeV1Project,
Expand All @@ -25,7 +27,6 @@ import {
Observable,
Subject,
combineLatest,
debounceTime,
distinctUntilChanged,
map,
pairwise,
Expand All @@ -36,6 +37,7 @@ import {
import { roundToNextQuarter } from '../../common/functions/round-to-next-quarter';
import { chargeCodeToColor } from '../../common/functions/charge-code-to-color';
import { TimeStatus } from '../../models/common/time-status';
import { ModalService } from '../../services/modal.service';

export interface HQTimeChangeEvent {
id?: string | null;
Expand Down Expand Up @@ -90,12 +92,14 @@ export class StaffDashboardTimeEntryComponent implements OnChanges, OnDestroy {

chargeCodeToColor = chargeCodeToColor;

@ViewChild('hoursInput') hoursInput!: ElementRef<HTMLInputElement>;

form = new FormGroup<Form>({
id: new FormControl<string | null>(null),
date: new FormControl<string | null>(null),
hours: new FormControl<number | null>(null, {
updateOn: 'blur',
validators: [Validators.required],
validators: [Validators.required, Validators.min(0.25)],
}),
notes: new FormControl<string | null>(null, {
updateOn: 'blur',
Expand All @@ -104,8 +108,14 @@ export class StaffDashboardTimeEntryComponent implements OnChanges, OnDestroy {
task: new FormControl<string | null>(null, { updateOn: 'blur' }),
chargeCode: new FormControl<string | null>(null),
chargeCodeId: new FormControl<string | null>(null, [Validators.required]),
clientId: new FormControl<string | null>(null, [Validators.required]),
projectId: new FormControl<string | null>(null, [Validators.required]),
clientId: new FormControl<string | null>(null, {
updateOn: 'change',
validators: [Validators.required],
}),
projectId: new FormControl<string | null>(null, {
updateOn: 'change',
validators: [Validators.required],
}),
activityId: new FormControl<string | null>(null),
});

Expand All @@ -114,7 +124,10 @@ export class StaffDashboardTimeEntryComponent implements OnChanges, OnDestroy {

timeStatus = TimeStatus;

constructor(public staffDashboardService: StaffDashboardService) {
constructor(
public staffDashboardService: StaffDashboardService,
private modalService: ModalService,
) {
const form$ = this.form.valueChanges.pipe(
shareReplay({ bufferSize: 1, refCount: false }),
);
Expand Down Expand Up @@ -179,7 +192,7 @@ export class StaffDashboardTimeEntryComponent implements OnChanges, OnDestroy {
});

// eslint-disable-next-line rxjs-angular/prefer-async-pipe
hours$.pipe(debounceTime(500), takeUntil(this.destroyed$)).subscribe({
hours$.pipe(takeUntil(this.destroyed$)).subscribe({
next: (hours) => {
if (hours != null) {
this.form.patchValue({ hours: roundToNextQuarter(hours) });
Expand All @@ -188,48 +201,49 @@ export class StaffDashboardTimeEntryComponent implements OnChanges, OnDestroy {
error: console.error,
});

form$
.pipe(
distinctUntilChanged(
(prev, curr) => JSON.stringify(prev) === JSON.stringify(curr),
),
debounceTime(750),
takeUntil(this.destroyed$),
)
// eslint-disable-next-line rxjs-angular/prefer-async-pipe
.subscribe({
next: (time) => {
if (this.form.touched && this.form.valid) {
this.hqTimeChange.emit(time);
}
},
error: console.error,
});

this.staffDashboardService.refresh$
.pipe(takeUntil(this.destroyed$))
// eslint-disable-next-line rxjs-angular/prefer-async-pipe
.subscribe({
next: () => {
if (!this.time?.id) {
this.form.reset();
this.form.patchValue({
date: this.time?.date,
});
this.resetTime();
}
},
error: console.error,
});
}

async onEnter(target: EventTarget | null) {
if (
target instanceof HTMLInputElement ||
target instanceof HTMLSelectElement
) {
target.blur();
}
}

async save() {
if (this.form.valid && this.form.dirty) {
this.hqTimeChange.emit(this.form.value);
this.form.markAsPristine();
if (!this.form.value.id) {
this.hoursInput?.nativeElement?.focus();
}
}
}

ngOnChanges(changes: SimpleChanges) {
if (changes['time'].currentValue) {
this.form.patchValue(changes['time'].currentValue);
if (this.form.value.id) {
// Force validation to run and highlight invalid fields red
this.form.markAllAsTouched();
}
}
}

ngOnDestroy() {
console.log('destroying');
this.destroyed$.next();
this.destroyed$.complete();
}
Expand All @@ -241,15 +255,12 @@ export class StaffDashboardTimeEntryComponent implements OnChanges, OnDestroy {
}
}
resetTime() {
this.form.reset();
this.form.reset({ date: this.form.controls.date.value });
}

blurInput(target: EventTarget | null) {
if (
target instanceof HTMLInputElement ||
target instanceof HTMLSelectElement
) {
target.blur();
async showRejectionNotes() {
if (this.time?.rejectionNotes) {
await this.modalService.alert('Rejection', this.time.rejectionNotes);
}
}
}
1 change: 0 additions & 1 deletion src/dotnet/HQ.Server/Services/TimeEntryServiceV1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ public TimeEntryServiceV1(HQDbContext context)
public async Task<Result<UpsertTimeV1.Response>> UpsertTimeV1(UpsertTimeV1.Request request, CancellationToken ct = default)
{
var validationResult = Result.Merge(
Result.FailIf(string.IsNullOrEmpty(request.Notes), "Notes are required."),
Result.FailIf(!request.Id.HasValue && request.StaffId == null, "Staff is required."),
Result.FailIf(request.Hours <= 0, "Hours must be greater than 0.")
);
Expand Down

0 comments on commit 79389b2

Please sign in to comment.