Skip to content

Commit

Permalink
Merge pull request #47
Browse files Browse the repository at this point in the history
Add list validation for time slot priorities and refactor time initialization
  • Loading branch information
raywo authored Dec 18, 2024
2 parents ed1a0e2 + 3aa03dc commit 6c9610b
Show file tree
Hide file tree
Showing 8 changed files with 166 additions and 39 deletions.
36 changes: 29 additions & 7 deletions src/app/persons/components/person-edit/person-edit.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -101,26 +101,46 @@ <h3>Time slots</h3>
<div class="col-8 slot-target">
<h4 class="h6">Preferred time slots</h4>

<input type="hidden"
id="priority-1-state"
name="priority-1-state"
required
minlength="1"
[(ngModel)]="timeSlotListState"
#priority1ListState="ngModel">

<div class="row">
@for (slots of timeSlots; track $index) {
@for (slots of priorityTimeSlots; track $index) {
@let index = $index;
<div class="col" [class.hidden]="!showTimeSlotPriority($index)">
<h5>Priority {{ $index + 1 }}</h5>
<h5>Priority {{ index + 1 }}</h5>

<div
cdkDropList
[cdkDropListData]="slots"
[cdkDropListData]="index"
(cdkDropListDropped)="onSlotDropped($event)"
class="slot-list"
[listMustNotBeEmpty]="slots"
[referenceControl]="priority1ListState"
[apply]="index === 0"
>

@for (slot of slots; track slot.id) {
<div class="slot-pill" cdkDrag>
<div class="slot-pill"
cdkDrag
[cdkDragData]="{index: index, slot: slot}">
<div class="slot-pill-placeholder" *cdkDragPlaceholder></div>
<app-time-slot-view [timeSlot]="slot"
[showButtons]="false"
[showDragHandle]="true"/>
</div>
} @empty {
<small class="text-muted">No preference given</small>
<div class="invalid-feedback">
Please select at least one time slot for this priority.
</div>
<div class="empty-list-hint">
<small class="text-muted">No preference given</small>
</div>
}
</div>
</div>
Expand All @@ -134,12 +154,14 @@ <h4 class="h6">Available time slots</h4>
<div
cdkDropList
cdkDropListSortingDisabled
[cdkDropListData]="timeSlotsSource"
[cdkDropListData]="-1"
(cdkDropListDropped)="onSlotDropped($event)"
class="slot-list"
>
@for (slot of timeSlotsSource; track slot.id) {
<div class="slot-pill" cdkDrag>
<div class="slot-pill"
cdkDrag
[cdkDragData]="{index: -1, slot: slot}">
<div class="slot-pill-placeholder" *cdkDragPlaceholder></div>
<app-time-slot-view [timeSlot]="slot"
[showButtons]="false"
Expand Down
63 changes: 46 additions & 17 deletions src/app/persons/components/person-edit/person-edit.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import {PriorKnowledgeService} from '../../../prior-knowledge/services/prior-kno
import {TimeSlotService} from '../../../timeslots/services/time-slot.service';
import {PersonService} from '../../services/person.service';
import {TimeSlotViewComponent} from '../../../timeslots/components/time-slot-view/time-slot-view.component';
import {CdkDrag, CdkDragDrop, CdkDragPlaceholder, CdkDropList, CdkDropListGroup, moveItemInArray, transferArrayItem} from '@angular/cdk/drag-drop';
import {CdkDrag, CdkDragDrop, CdkDragPlaceholder, CdkDropList, CdkDropListGroup} from '@angular/cdk/drag-drop';
import {createPersonKnowledge, PersonKnowledge} from '../../models/person-knowledge.model';
import {createPersonTimeSlot, PersonTimeSlot} from '../../models/person-timeslot.model';
import {ListMustNotBeEmptyDirective} from '../../directives/list-must-not-be-empty.directive';


interface PriorKnowledgeSelection {
Expand All @@ -27,7 +28,8 @@ interface PriorKnowledgeSelection {
CdkDropList,
CdkDrag,
CdkDropListGroup,
CdkDragPlaceholder
CdkDragPlaceholder,
ListMustNotBeEmptyDirective
],
templateUrl: './person-edit.component.html',
styleUrl: './person-edit.component.scss'
Expand All @@ -49,9 +51,12 @@ export class PersonEditComponent {

protected name: string = "";
protected info: string = "";
// This is just needed to make the form valid or invalid according to the
// time slot list for priority 1.
protected timeSlotListState: string = "";

protected knowledge: PriorKnowledgeSelection[] = [];
protected timeSlots: TimeSlot[][] = [];
protected priorityTimeSlots: TimeSlot[][] = [];
protected timeSlotsSource: TimeSlot[] = [];


Expand Down Expand Up @@ -115,15 +120,33 @@ export class PersonEditComponent {
}


protected onSlotDropped(dropEvent: CdkDragDrop<TimeSlot[], any>) {
if (dropEvent.previousContainer === dropEvent.container) {
moveItemInArray(dropEvent.container.data, dropEvent.previousIndex, dropEvent.currentIndex);
} else {
transferArrayItem(dropEvent.previousContainer.data,
dropEvent.container.data,
dropEvent.previousIndex,
dropEvent.currentIndex);
protected onSlotDropped(dropEvent: CdkDragDrop<number, any>) {
const targetIndex = dropEvent.container.data;
const data = dropEvent.item.data;
const sourceIndex = data.index;
const slot = data.slot;
// console.log("dragged data", data, "target index", targetIndex, "source index", sourceIndex, "slot", slot, "event", dropEvent);

if (targetIndex === sourceIndex) return;

if (sourceIndex === -1) {
this.timeSlotsSource = this.timeSlotsSource.filter(s => s.id !== slot.id);
this.priorityTimeSlots[targetIndex] = [...this.priorityTimeSlots[targetIndex], slot]
.sort((a, b) => a.start.compareTo(b.start));
return;
}

if (targetIndex === -1) {
this.priorityTimeSlots[sourceIndex] = this.priorityTimeSlots[sourceIndex].filter(s => s.id !== slot.id);
this.timeSlotsSource = [...this.timeSlotsSource, slot]
.sort((a, b) => a.start.compareTo(b.start));
return;
}

this.priorityTimeSlots[sourceIndex] = this.priorityTimeSlots[sourceIndex].filter(s => s.id !== slot.id);
this.priorityTimeSlots[targetIndex] = [...this.priorityTimeSlots[targetIndex], slot]
.sort((a, b) => a.start.compareTo(b.start));

}


Expand All @@ -132,8 +155,8 @@ export class PersonEditComponent {
return true;
}

return this.timeSlots
.slice(index - 1, this.timeSlots.length)
return this.priorityTimeSlots
.slice(index - 1, this.priorityTimeSlots.length)
.some(slot => slot.length !== 0);
}

Expand All @@ -142,7 +165,7 @@ export class PersonEditComponent {
let priorKnowledge: PersonKnowledge[] = this.knowledge
.filter(k => k.selected)
.map(k => createPersonKnowledge(k.knowledge, k.remark));
let personTimeSlots: PersonTimeSlot[] = this.timeSlots
let personTimeSlots: PersonTimeSlot[] = this.priorityTimeSlots
.map((s, index) => {
return s.map(slot => createPersonTimeSlot(slot, index + 1))
})
Expand Down Expand Up @@ -172,14 +195,20 @@ export class PersonEditComponent {

private fillTimeSlots(person: Person) {
const length = this._timeSlotSource.length
this.timeSlots = new Array(length).fill(0).map(() => []);
this.priorityTimeSlots = new Array(length).fill(0).map(() => []);

person.timeSlots.forEach(ts => {
const priority = ts.priority || 1;
this.timeSlots[priority - 1].push(ts.timeSlot);
this.priorityTimeSlots[priority - 1].push(ts.timeSlot);
});

if (this.priorityTimeSlots[0].length > 0) {
this.timeSlotListState = "not empty";
}

const personTimeSlots = person.timeSlots.map(t => t.timeSlot);
this.timeSlotsSource = this._timeSlotSource.filter(s => !personTimeSlots.includes(s));
this.timeSlotsSource = this._timeSlotSource.filter(s => {
return !personTimeSlots.some(pts => pts.id === s.id);
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import {ListMustNotBeEmptyDirective} from './list-must-not-be-empty.directive';


describe('ListMustNotBeEmptyDirective', () => {
it('should create an instance', () => {
const directive = new ListMustNotBeEmptyDirective();
expect(directive).toBeTruthy();
});
});
46 changes: 46 additions & 0 deletions src/app/persons/directives/list-must-not-be-empty.directive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {Directive, effect, ElementRef, inject, input, OnInit, Renderer2} from '@angular/core';
import {NgModel} from '@angular/forms';


@Directive({
selector: '[listMustNotBeEmpty]'
})
export class ListMustNotBeEmptyDirective implements OnInit {

private element = inject(ElementRef);
private renderer = inject(Renderer2);

public listMustNotBeEmpty = input.required<any[]>();
public referenceControl = input.required<NgModel>();
public apply = input.required<boolean>();


constructor() {
effect(() => {
const list = this.listMustNotBeEmpty();
this.validate(list);
});
}


public ngOnInit(): void {
this.validate(this.listMustNotBeEmpty());
}


private validate(list: any[]) {
if (!this.apply()) return;

if (list.length === 0) {
this.renderer.addClass(this.element.nativeElement, "border-danger");
this.renderer.addClass(this.element.nativeElement, "invalid-list");
this.referenceControl().control.setValue("");
this.referenceControl().control.updateValueAndValidity();
} else {
this.renderer.removeClass(this.element.nativeElement, "border-danger");
this.renderer.removeClass(this.element.nativeElement, "invalid-list");
this.referenceControl().control.setValue("not empty");
this.referenceControl().control.updateValueAndValidity();
}
}
}
5 changes: 2 additions & 3 deletions src/app/persons/services/person.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {PersonTimeSlot} from '../models/person-timeslot.model';
import {Time} from '../../timeslots/models/time.model';
import {randomNumber} from '../../shared/helper/random';
import {SortOrder, stringCompare, timeCompare} from '../../shared/helper/comparison';
import {Team} from '../../teams/models/team.model';


@Injectable({
Expand Down Expand Up @@ -150,14 +149,14 @@ export class PersonService {
}


public getRandomAvailablePerson(team: Team): Person | undefined {
public getRandomAvailablePerson(timeSlotId: string): Person | undefined {
if (this.availablePersons.length === 0) {
return undefined;
}

const candidates = this.availablePersons
.filter(p =>
p.timeSlots.some(t => t.timeSlot.id === team.timeSlot.id));
p.timeSlots.some(t => t.timeSlot.id === timeSlotId));
const randomIndex = randomNumber(candidates.length - 1);
const person = candidates[randomIndex];

Expand Down
10 changes: 10 additions & 0 deletions src/app/shared/services/persistence.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {TimeSlotService} from '../../timeslots/services/time-slot.service';
import {Team} from '../../teams/models/team.model';
import {TimeSlot} from '../../timeslots/models/time-slot.model';
import {PriorKnowledge} from '../../prior-knowledge/models/prior-knowledge.model';
import {Time} from '../../timeslots/models/time.model';


@Injectable({
Expand Down Expand Up @@ -57,6 +58,13 @@ export class PersistenceService {
const persons = JSON.parse(rawPersons) as Person[];

persons.forEach(person => {
person.timeSlots
.map(ts => ts.timeSlot)
.forEach(slot => {
slot.start = Time.fromSimpleTime(slot.start);
slot.end = Time.fromSimpleTime(slot.end);
}
);
this.personService.addPerson(person, true);
});
}
Expand Down Expand Up @@ -97,6 +105,8 @@ export class PersistenceService {
const slots = JSON.parse(rawSlots) as TimeSlot[];

slots.forEach(slot => {
slot.start = Time.fromSimpleTime(slot.start);
slot.end = Time.fromSimpleTime(slot.end);
this.slotService.addSlot(slot, true);
});
}
Expand Down
34 changes: 23 additions & 11 deletions src/app/styles/persons.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@
width: 75% !important;
}

.slot-pill {
border: 1px solid var(--bs-light);
border-radius: 0.5rem;
padding: 0.25rem 0.75rem;
margin-bottom: 0.5rem;

background: var(--bs-body-bg);

.slot-pill-title {
font-size: var(--bs-font-size);
font-weight: bold;
}
}

.person-form {
h3 {
Expand Down Expand Up @@ -46,19 +59,19 @@
border-radius: 0.5rem;
padding: 0.5rem 0.5rem 1rem;
min-height: 5rem;
}

.slot-pill {
border: 1px solid var(--bs-light);
border-radius: 0.5rem;
padding: 0.25rem 0.75rem;
margin-bottom: 0.5rem;
&.invalid-list {
.invalid-feedback {
display: block;
}

background: var(--bs-body-bg);
.empty-list-hint {
display: none;
}
}

.slot-pill-title {
font-size: var(--bs-font-size);
font-weight: bold;
.empty-list-hint {
display: block;
}
}
}
Expand Down Expand Up @@ -146,7 +159,6 @@
border: 1px solid var(--bs-light);
border-radius: 0.5rem;
background-color: var(--bs-tertiary-bg);

padding: 0.5rem;

.popover {
Expand Down
2 changes: 1 addition & 1 deletion src/app/teams/services/team-assembly.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export class TeamAssemblyService {

teams.forEach(team => {
while (team.persons.length < personsPerTeam) {
const person = this.personService.getRandomAvailablePerson(team);
const person = this.personService.getRandomAvailablePerson(team.timeSlot.id);

if (!person) break;

Expand Down

0 comments on commit 6c9610b

Please sign in to comment.