Skip to content

Commit

Permalink
Merge pull request #382 from fujaba/feat/copy-assignees
Browse files Browse the repository at this point in the history
Copy Assignees
  • Loading branch information
Clashsoft authored Nov 8, 2023
2 parents aecd137 + 65fd31d commit df42f0e
Show file tree
Hide file tree
Showing 14 changed files with 162 additions and 35 deletions.
1 change: 1 addition & 0 deletions frontend/src/app/assignment/model/assignee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,5 @@ export interface Assignee {
}

export type UpdateAssigneeDto = Omit<Assignee, 'assignment' | 'solution'>;
export type BulkUpdateAssigneeDto = Omit<Assignee, 'assignment'>;
export type PatchAssigneeDto = Partial<UpdateAssigneeDto>;
2 changes: 1 addition & 1 deletion frontend/src/app/assignment/model/solution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export default class Solution {
commit?: string;
consent?: Consent;

timestamp?: Date;
timestamp?: string;
points?: number;
feedback?: Feedback;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@
<th>
Submitted
<button class="btn btn-sm p-1 bi-question-circle" ngbTooltip="View Color Legend" [ngbPopover]="timestampPopover" placement="bottom-left"></button>
<button class="btn btn-sm p-1 bi-clipboard" ngbTooltip="Copy Column as Text" (click)="copyTimestamp()"></button>
<ng-template #timestampPopover>
<dl>
<ng-container *ngIf="assignment && assignment.deadline">
Expand Down Expand Up @@ -118,7 +119,10 @@
</ng-template>
<button class="btn btn-sm p-1 bi-clipboard" ngbTooltip="Copy Column as Text" (click)="copyPoints()"></button>
</th>
<th>Assignee</th>
<th>
Assignee
<button class="btn btn-sm p-1 bi-clipboard" ngbTooltip="Copy Column as Text" (click)="copyAssignee()"></button>
</th>
<th>Actions</th>
</tr>
</thead>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,13 +190,31 @@ export class SolutionTableComponent implements OnInit {
return [...valueSet].sort();
}

copyTimestamp() {
this.copy('Timestamp', s => {
if (!s.timestamp) {
return '';
}
const date = new Date(s.timestamp);
// format as YYYY-MM-DD HH:mm:ss (local time) to be understood by Excel
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()} ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`;
});
}

copyPoints() {
this.clipboardService.copy(this.solutions!.map(s => s.points ?? '').join('\n'));
this.toastService.success('Copy Points', `Copied ${this.solutions.length} rows to clipboard`);
this.copy('Points', s => (s.points || '').toString());
}

copyAssignee() {
this.copy('Assignees', s => this.assignees[s._id!] || '');
}

copyAuthor(name: string, key: keyof AuthorInfo) {
this.clipboardService.copy(this.solutions!.map(s => s.author[key] ?? '').join('\n'));
this.copy(name, s => s.author[key] || '');
}

copy(name: string, select: (s: Solution) => string) {
this.clipboardService.copy(this.solutions!.map(select).join('\n'));
this.toastService.success(`Copy ${name}`, `Copied ${this.solutions.length} rows to clipboard`);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,31 @@
<th *ngFor="let prop of authorProperties">
{{ prop[0] }}
</th>
<th *ngFor="let assignment of assignments; let index = index" colspan="2"
[routerLink]="assignment && '/assignments/' + assignment._id + '/solutions'"
[ngbTooltip]="assignment && assignment.title" container="body"
class="bi-clipboard-check cursor-pointer"
>
{{ assignmentNames[index] }}
<th *ngFor="let assignment of assignments; let index = index" colspan="2">
<div class="d-flex align-items-end">
<a class="bi-clipboard-check me-auto text-body"
[routerLink]="assignment && '/assignments/' + assignment._id + '/solutions'"
[ngbTooltip]="assignment && assignment.title" container="body">
{{ assignmentNames[index] }}
</a>
<div ngbDropdown>
<button class="btn btn-sm btn-outline-secondary bi-person-check" ngbDropdownToggle
ngbTooltip="Apply Assignees" placement="top">
</button>
<div ngbDropdownMenu>
<div class="dropdown-item-text">
Copy Assignees from
</div>
<button ngbDropdownItem
*ngFor="let otherAssignment of assignments; let otherIndex = index"
[disabled]="otherAssignment === assignment"
(click)="copyAssignees(index, otherIndex)"
>
{{ otherAssignment?.title }}
</button>
</div>
</div>
</div>
</th>
<th>
<i class="bi-person-raised-hand" ngbTooltip="Number of complete Feedbacks submitted"></i>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import {CourseService} from 'src/app/assignment/services/course.service';
import Course, {CourseStudent} from '../../../model/course';
import {authorInfoProperties} from '../../../model/solution';
import {AssignmentService} from "../../../services/assignment.service";
import Assignment, {ReadAssignmentDto} from "../../../model/assignment";
import {ReadAssignmentDto} from "../../../model/assignment";
import {AssigneeService} from "../../../services/assignee.service";
import {BulkUpdateAssigneeDto} from "../../../model/assignee";
import {ToastService} from "@mean-stream/ngbx";

@Component({
selector: 'app-students',
Expand All @@ -25,6 +28,8 @@ export class StudentsComponent implements OnInit {
private route: ActivatedRoute,
private courseService: CourseService,
private assignmentService: AssignmentService,
private assigneeService: AssigneeService,
private toastService: ToastService,
) {
}

Expand Down Expand Up @@ -62,4 +67,38 @@ export class StudentsComponent implements OnInit {
)].sort();
});
}

copyAssignees(to: number, from: number) {
if (!this.course || !this.students) {
return;
}
this.assigneeService.updateMany(this.course.assignments[to], this.students
.map(student => {
const fromSolution = student.solutions[from];
const toSolution = student.solutions[to];
if (!fromSolution || !toSolution || !fromSolution.assignee) {
return;
}
return {
solution: toSolution._id,
assignee: fromSolution.assignee,
};
})
.filter((x): x is BulkUpdateAssigneeDto => !!x),
).subscribe(assignees => {
const solutionIdToAssignee = Object.fromEntries(assignees.map(a => [a.solution, a.assignee]));
for (const student of this.students!) {
const solution = student.solutions[to];
if (!solution) {
continue;
}
const assignee = solutionIdToAssignee[solution._id];
if (!assignee) {
continue;
}
solution.assignee = assignee;
}
this.toastService.success('Copy Assignees', `Successfully copied ${assignees.length} assignees.`);
});
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<p>
Import consent flags by pasting tab-, space- or comma-separated values below.
Import consent flags by pasting tab- or comma-separated values below.
</p>
<details>
<details class="alert alert-info">
<summary>More Info</summary>
<p>
The first line should specify the column names (case insensitive), for example:
Expand All @@ -19,4 +19,10 @@
and accept boolean values like <code>true</code> or <code>false</code> (case-insensitive).
</p>
</details>
<textarea class="form-control" rows="10" [(ngModel)]="consentText"></textarea>
<textarea
#area
class="form-control"
rows="10"
[(ngModel)]="consentText"
(keydown.tab)="$event.preventDefault(); area.setRangeText('\t', area.selectionStart, area.selectionEnd, 'end')"
></textarea>
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export class ImportConsentComponent {
import(): Observable<ImportSolution[]> {
const assignment = this.route.snapshot.params.aid;
const lines = this.consentText.split('\n');
const splitter = /[\s,;]/;
const splitter = /[\t,;]/;
const columns = lines[0].split(splitter);
const updates: Partial<Solution>[] = [];
for (let i = 1; i < lines.length; i++) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
<div ngbDropdown>
<div ngbDropdown #dropdown=ngbDropdown>
<button ngbDropdownToggle class="btn p-0 text-white text-shadow"
[class.bi-person-plus]="!assignee"
[ngbTooltip]="assignee || 'Add Assignee'"
>
{{ (assignee || '') | initials }}
</button>
<div ngbDropdownMenu>
<button *ngFor="let suggestion of assignees" ngbDropdownItem (click)="assignee = suggestion; save()">
<span class="badge" [style.background-color]="suggestion | assigneeColor">{{ suggestion | initials }}</span>
{{ suggestion }}
</button>
<button ngbDropdownItem class="bi-person-x" (click)="this.assignee = ''; save()">
Unassign
</button>
<div class="dropdown-item-text">
<label class="form-label" for="custom-assignee">
Custom Assignee
</label>
<input type="text" class="form-control" id="custom-assignee" [(ngModel)]="assignee" (change)="save()">
</div>
<ng-container *ngIf="dropdown.isOpen()">
<button *ngFor="let suggestion of assignees" ngbDropdownItem (click)="save(suggestion)">
<span class="badge" [style.background-color]="suggestion | assigneeColor">{{ suggestion | initials }}</span>
{{ suggestion }}
</button>
<button ngbDropdownItem class="bi-person-x" (click)="save(undefined)">
Unassign
</button>
<div class="dropdown-item-text">
<label class="form-label" for="custom-assignee">
Custom Assignee
</label>
<input type="text" class="form-control" id="custom-assignee" [(ngModel)]="assignee" (change)="save(assignee)">
</div>
</ng-container>
</div>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,18 @@ export class AssigneeDropdownComponent {
) {
}

save(): void {
this.assigneeService.setAssignee(this.assignment, this.solution, this.assignee).subscribe(result => {
save(assignee: string | undefined): void {
if (!assignee) {
if (!this.assignee) {
// nothing to do; prevent an unnecessary API call that errors
return;
}
if (!confirm('Are you sure you want to unassign? This may remove the recorded duration and feedback.')) {
return;
}
}
this.assigneeService.setAssignee(this.assignment, this.solution, assignee).subscribe(result => {
this.assignee = result?.assignee;
this.assigneeChange.next(result?.assignee);
this.toastService.success('Assignee', result ? `Successfully assigned to ${result.assignee}` : 'Successfully de-assigned');
}, error => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,17 @@ export class AssigneeColorPipe implements PipeTransform {
if (!value) {
return undefined;
}
const hash = value.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
const hash = hashCode(value);
const hue = (hash % 30) * 12;
return `hsl(${hue}, 50%, 50%)`;
}
}

function hashCode(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0;
}
return hash;
}
6 changes: 5 additions & 1 deletion frontend/src/app/assignment/services/assignee.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {Injectable} from "@angular/core";
import {Observable} from "rxjs";
import {Assignee, PatchAssigneeDto, UpdateAssigneeDto} from "../model/assignee";
import {Assignee, BulkUpdateAssigneeDto, PatchAssigneeDto, UpdateAssigneeDto} from "../model/assignee";
import {environment} from "../../../environments/environment";
import {HttpClient} from "@angular/common/http";
import {map} from "rxjs/operators";
Expand All @@ -20,6 +20,10 @@ export class AssigneeService {
return this.http.get<Assignee[]>(`${environment.assignmentsApiUrl}/assignments/${assignment}/assignees`);
}

updateMany(assignment: string, dtos: BulkUpdateAssigneeDto[]): Observable<Assignee[]> {
return this.http.patch<Assignee[]>(`${environment.assignmentsApiUrl}/assignments/${assignment}/assignees`, dtos);
}

findOne(assignment: string, solution: string): Observable<Assignee> {
return this.http.get<Assignee>(url(assignment, solution));
}
Expand Down
12 changes: 11 additions & 1 deletion services/apps/assignments/src/assignee/assignee.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {NotFound} from '@mean-stream/nestx';
import {Body, Controller, Delete, Get, Param, Patch, Put} from '@nestjs/common';
import {ApiOkResponse, ApiTags} from '@nestjs/swagger';
import {AssignmentAuth} from '../assignment/assignment-auth.decorator';
import {PatchAssigneeDto, UpdateAssigneeDto} from './assignee.dto';
import {BulkUpdateAssigneeDto, PatchAssigneeDto, UpdateAssigneeDto} from './assignee.dto';
import {Assignee} from './assignee.schema';
import {AssigneeService} from './assignee.service';

Expand All @@ -25,6 +25,16 @@ export class AssigneeController {
return this.assigneeService.findAll({assignment}, {sort: {assignee: 1}});
}

@Patch('assignments/:assignment/assignees')
@AssignmentAuth({forbiddenResponse})
@ApiOkResponse({type: Assignee})
async updateMany(
@Param('assignment') assignment: string,
@Body() dtos: BulkUpdateAssigneeDto[],
): Promise<Assignee[]> {
return Promise.all(dtos.map(dto => this.assigneeService.upsert({assignment, solution: dto.solution}, dto)));
}

@Get('assignments/:assignment/solutions/:solution/assignee')
@NotFound()
@AssignmentAuth({forbiddenResponse})
Expand Down
5 changes: 5 additions & 0 deletions services/apps/assignments/src/assignee/assignee.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ export class UpdateAssigneeDto extends OmitType(Assignee, [

export class PatchAssigneeDto extends PartialType(UpdateAssigneeDto) {
}

export class BulkUpdateAssigneeDto extends OmitType(Assignee, [
'assignment',
] as const) {
}

0 comments on commit df42f0e

Please sign in to comment.