Skip to content

Commit

Permalink
General: Add course exercise overview grouped by due date (timeframe …
Browse files Browse the repository at this point in the history
…view) (#7363)
  • Loading branch information
florian-glombik authored Oct 15, 2023
1 parent 6fa10af commit eca9020
Show file tree
Hide file tree
Showing 16 changed files with 462 additions and 95 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
class="test-schedule-date px-1"
label="artemisApp.exercise.releaseDate"
tooltipText="artemisApp.programmingExercise.timeline.releaseDateTooltip"
></jhi-programming-exercise-test-schedule-date-picker>
/>
<jhi-programming-exercise-test-schedule-date-picker
*ngIf="!isExamMode"
[(ngModel)]="exercise.startDate"
Expand All @@ -21,7 +21,7 @@
class="test-schedule-date px-1"
label="artemisApp.exercise.startDate"
tooltipText="artemisApp.programmingExercise.timeline.startDateTooltip"
></jhi-programming-exercise-test-schedule-date-picker>
/>

<div class="test-schedule-date px-1">
<div class="test-schedule-date-title test-schedule-date-title-small-button">
Expand All @@ -44,8 +44,8 @@
class="test-schedule-date px-1"
label="artemisApp.exercise.dueDate"
tooltipText="artemisApp.programmingExercise.timeline.dueDateTooltip"
>
</jhi-programming-exercise-test-schedule-date-picker>
id="programming-exercise-due-date-picker"
/>
<jhi-programming-exercise-test-schedule-date-picker
*ngIf="isExamMode || exercise.dueDate"
class="test-schedule-date px-1"
Expand All @@ -55,8 +55,7 @@
[readOnly]="readOnly"
label="artemisApp.programmingExercise.timeline.afterDueDate"
tooltipText="artemisApp.programmingExercise.timeline.afterDueDateTooltip"
>
</jhi-programming-exercise-test-schedule-date-picker>
/>

<div *ngIf="isExamMode || exercise.dueDate">
<div class="test-schedule-date px-1">
Expand Down Expand Up @@ -94,7 +93,7 @@
[readOnly]="readOnly"
label="artemisApp.programmingExercise.timeline.assessmentDueDate"
tooltipText="artemisApp.programmingExercise.timeline.assessmentDueDateTooltip"
></jhi-programming-exercise-test-schedule-date-picker>
/>
</div>

<div *ngIf="!isExamMode">
Expand All @@ -106,8 +105,7 @@
class="test-schedule-date px-1"
label="artemisApp.exercise.exampleSolutionPublicationDate"
tooltipText="artemisApp.programmingExercise.timeline.exampleSolutionPublicationDateTooltip"
>
</jhi-programming-exercise-test-schedule-date-picker>
/>
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { OrionModule } from 'app/shared/orion/orion.module';
import { GradingKeyOverviewModule } from 'app/grading-system/grading-key-overview/grading-key-overview.module';
import { SubmissionResultStatusModule } from 'app/overview/submission-result-status.module';
import { ExerciseCategoriesModule } from 'app/shared/exercise-categories/exercise-categories.module';
import { CourseExercisesGroupedByWeekComponent } from 'app/overview/course-exercises/course-exercises-grouped-by-week.component';
import { CourseExercisesGroupedByCategoryComponent } from 'app/overview/course-exercises/course-exercises-grouped-by-category.component';

@NgModule({
imports: [
Expand All @@ -24,7 +26,7 @@ import { ExerciseCategoriesModule } from 'app/shared/exercise-categories/exercis
SubmissionResultStatusModule,
ExerciseCategoriesModule,
],
declarations: [CourseExerciseRowComponent],
exports: [CourseExerciseRowComponent],
declarations: [CourseExerciseRowComponent, CourseExercisesGroupedByWeekComponent, CourseExercisesGroupedByCategoryComponent],
exports: [CourseExerciseRowComponent, CourseExercisesGroupedByWeekComponent, CourseExercisesGroupedByCategoryComponent],
})
export class ArtemisCourseExerciseRowModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<div *ngIf="!filteredExercises?.length; else exercisesWithAppliedSearchAndFilters">
{{ 'artemisApp.courseOverview.exerciseList.noExerciseMatchesSearchAndFilters' | artemisTranslate }}
</div>
<ng-template #exercisesWithAppliedSearchAndFilters>
<div class="exercise-row-container mb-3" *ngFor="let exerciseGroupKey of Object.keys(exerciseGroups)" (click)="toggleGroupCategoryCollapse(exerciseGroupKey)">
<ng-container *ngIf="exerciseGroups[exerciseGroupKey].exercises.length">
<div class="control-label d-flex align-items-center">
<div class="icon-container pe-3">
<fa-icon
[icon]="faChevronRight"
class="rotate-icon chevron-position"
[class.rotated]="!exerciseGroups[exerciseGroupKey].isCollapsed"
[class.text-primary]="exerciseGroupKey === 'current'"
></fa-icon>
</div>

<ng-container [ngSwitch]="exerciseGroupKey">
<h3 *ngSwitchCase="'past'" class="mb-0">{{ 'artemisApp.courseOverview.exerciseList.past' | artemisTranslate }}</h3>
<h3 *ngSwitchCase="'current'" class="text-primary mb-0">{{ 'artemisApp.courseOverview.exerciseList.current' | artemisTranslate }}</h3>
<h3 *ngSwitchCase="'future'" class="mb-0">{{ 'artemisApp.courseOverview.exerciseList.future' | artemisTranslate }}</h3>
<h3 *ngSwitchCase="'noDueDate'" class="mb-0">{{ 'artemisApp.courseOverview.exerciseList.noDueDate' | artemisTranslate }}</h3>
</ng-container>
</div>

<div [ngbCollapse]="exerciseGroups[exerciseGroupKey].isCollapsed">
<jhi-course-exercise-row
class="pb-1"
[exercise]="exercise"
[course]="course!"
[hasGuidedTour]="exercise === exerciseForGuidedTour"
*ngFor="let exercise of exerciseGroups[exerciseGroupKey].exercises"
/>
</div>

<div class="collapsed"></div>
</ng-container>
</div>
</ng-template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { Component, Input, OnChanges } from '@angular/core';
import { Exercise } from 'app/entities/exercise.model';
import { Course } from 'app/entities/course.model';
import dayjs from 'dayjs/esm/';
import { faAngleDown, faAngleUp, faChevronRight } from '@fortawesome/free-solid-svg-icons';
import { cloneDeep } from 'lodash-es';

type ExerciseGroupCategory = 'past' | 'current' | 'future' | 'noDueDate';

/**
* {@link ExerciseGroupCategory#past} is always collapsed by default
*/
const DEFAULT_EXPAND_ORDER: ExerciseGroupCategory[] = ['current', 'future', 'noDueDate'];

type ExerciseGroups = Record<ExerciseGroupCategory, { exercises: Exercise[]; isCollapsed: boolean }>;

const DEFAULT_EXERCISE_GROUPS = {
past: { exercises: [], isCollapsed: true },
current: { exercises: [], isCollapsed: false },
future: { exercises: [], isCollapsed: false },
noDueDate: { exercises: [], isCollapsed: true },
};

@Component({
selector: 'jhi-course-exercises-grouped-by-category',
templateUrl: './course-exercises-grouped-by-category.component.html',
styleUrls: ['../course-overview.scss'],
})
export class CourseExercisesGroupedByCategoryComponent implements OnChanges {
protected readonly Object = Object;

@Input() filteredExercises?: Exercise[];
@Input() course?: Course;
@Input() exerciseForGuidedTour?: Exercise;
@Input() appliedSearchString?: string;

exerciseGroups: ExerciseGroups;

searchWasActive: boolean = false;
exerciseGroupsBeforeSearch: ExerciseGroups = cloneDeep(DEFAULT_EXERCISE_GROUPS);

faAngleUp = faAngleUp;
faAngleDown = faAngleDown;
faChevronRight = faChevronRight;

ngOnChanges() {
this.exerciseGroups = this.groupExercisesByDueDate();
}

toggleGroupCategoryCollapse(exerciseGroupCategoryKey: string) {
this.exerciseGroups[exerciseGroupCategoryKey].isCollapsed = !this.exerciseGroups[exerciseGroupCategoryKey].isCollapsed;
}

private groupExercisesByDueDate(): ExerciseGroups {
const updatedExerciseGroups: ExerciseGroups = cloneDeep(DEFAULT_EXERCISE_GROUPS);

if (!this.filteredExercises) {
return updatedExerciseGroups;
}

for (const exercise of this.filteredExercises) {
const exerciseGroup = this.getExerciseGroup(exercise);
updatedExerciseGroups[exerciseGroup].exercises.push(exercise);
}

this.adjustExpandedOrCollapsedStateOfExerciseGroups(updatedExerciseGroups);

return updatedExerciseGroups;
}

private expandAllExercisesAndSaveStateBeforeSearch(exerciseGroups: ExerciseGroups) {
const isAConsecutiveSearchWithAllGroupsExpanded = this.searchWasActive;
if (!isAConsecutiveSearchWithAllGroupsExpanded) {
this.exerciseGroupsBeforeSearch = cloneDeep(this.exerciseGroups);
this.searchWasActive = true;
}

Object.entries(exerciseGroups).forEach(([, exerciseGroup]) => {
exerciseGroup.isCollapsed = false;
});
}

private restoreStateBeforeSearch(exerciseGroups: ExerciseGroups) {
this.searchWasActive = false;

Object.entries(exerciseGroups).forEach(([exerciseGroupKey, exerciseGroup]) => {
exerciseGroup.isCollapsed = this.exerciseGroupsBeforeSearch[exerciseGroupKey].isCollapsed;
});
}

private keepCurrentCollapsedOrExpandedStateOfExerciseGroups(exerciseGroups: ExerciseGroups) {
Object.entries(exerciseGroups).forEach(([exerciseGroupKey, exerciseGroup]) => {
exerciseGroup.isCollapsed = this.exerciseGroups[exerciseGroupKey].isCollapsed;
});
}

/**
* Expand at least one exercise group, considering that {@link ExerciseGroupCategory#past} shall never be expanded by default
*
* Expanded by the order {@link ExerciseGroupCategory#current}, {@link ExerciseGroupCategory#future}, {@link ExerciseGroupCategory#noDueDate}
*/
private makeSureAtLeastOneExerciseGroupIsExpanded(exerciseGroups: ExerciseGroups) {
const exerciseGroupsWithExercises = Object.entries(exerciseGroups).filter(([, exerciseGroup]) => exerciseGroup.exercises.length > 0);
const expandedExerciseGroups = exerciseGroupsWithExercises.filter(([exerciseGroupKey, exerciseGroup]) => !exerciseGroup.isCollapsed && exerciseGroupKey !== 'past');

const atLeastOneExerciseIsExpanded = expandedExerciseGroups.length > 0;
const expandableGroupsExist = !atLeastOneExerciseIsExpanded && exerciseGroupsWithExercises.length > 0;

if (!expandableGroupsExist) {
return;
}

for (const exerciseGroupKey of DEFAULT_EXPAND_ORDER) {
const groupToExpand = exerciseGroupsWithExercises.find(([key]) => key === exerciseGroupKey);
if (groupToExpand) {
groupToExpand![1].isCollapsed = false;
break;
}
}
}

/**
* 1. Expand all sections with matches on search
* 2. Keep the expanded or collapsed state of the exercise groups when a filter is applied
* 3. Make sure at least one displayed section is expanded by default
*
* @param exerciseGroups updated and grouped exercises that are to be displayed
*/
private adjustExpandedOrCollapsedStateOfExerciseGroups(exerciseGroups: ExerciseGroups) {
const isSearchingExercise = this.appliedSearchString;
if (isSearchingExercise) {
return this.expandAllExercisesAndSaveStateBeforeSearch(exerciseGroups);
}

if (this.searchWasActive) {
return this.restoreStateBeforeSearch(exerciseGroups);
}

const filterIsApplied = this.exerciseGroups;
if (filterIsApplied) {
this.keepCurrentCollapsedOrExpandedStateOfExerciseGroups(exerciseGroups);
}

this.makeSureAtLeastOneExerciseGroupIsExpanded(exerciseGroups);
}

private getExerciseGroup(exercise: Exercise): ExerciseGroupCategory {
if (!exercise.dueDate) {
return 'noDueDate';
}

const dueDate = dayjs(exercise.dueDate);
const now = dayjs();

const dueDateIsInThePast = dueDate.isBefore(now);
if (dueDateIsInThePast) {
return 'past';
}

const dueDateIsWithinNextWeek = dueDate.isBefore(now.add(1, 'week'));
if (dueDateIsWithinNextWeek) {
return 'current';
}

return 'future';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<div class="exercise-row-container mb-3" *ngIf="nextRelevantExercise && isVisibleToStudents(nextRelevantExercise.exercise)">
<h3 class="text-primary">
<span *ngIf="nextRelevantExercise!.dueDate; else noDueDate">
{{ 'artemisApp.courseOverview.exerciseList.currentExerciseGroupHeader' | artemisTranslate: { date: nextRelevantExercise.dueDate | artemisDate } }}
</span>
<ng-template #noDueDate>
{{ 'artemisApp.courseOverview.exerciseList.currentExerciseGroupHeaderWithoutDueDate' | artemisTranslate }}
</ng-template>
</h3>
<jhi-course-exercise-row
class="pb-1"
id="next-course-exercise-row"
[exercise]="nextRelevantExercise!.exercise"
[course]="course"
[hasGuidedTour]="nextRelevantExercise!.exercise === exerciseForGuidedTour"
/>
<div class="collapsed"></div>
</div>

<div class="guided-tour exercise-row-container mb-3" *ngFor="let weekKey of weeklyIndexKeys">
<div
class="control-label"
[ngClass]="{ 'text-primary': immutableWeeklyExercisesGrouped[weekKey] ? immutableWeeklyExercisesGrouped[weekKey].isCurrentWeek : false }"
(click)="weeklyExercisesGrouped[weekKey].isCollapsed = !weeklyExercisesGrouped[weekKey].isCollapsed"
>
<fa-icon class="pe-3" [icon]="immutableWeeklyExercisesGrouped[weekKey].isCollapsed ? faAngleDown : faAngleUp"></fa-icon>
<span *ngIf="immutableWeeklyExercisesGrouped[weekKey].start && immutableWeeklyExercisesGrouped[weekKey].end">
{{ immutableWeeklyExercisesGrouped[weekKey].start | artemisDate: 'short-date' }} -
{{ immutableWeeklyExercisesGrouped[weekKey].end | artemisDate: 'short-date' }}
</span>
<span *ngIf="!immutableWeeklyExercisesGrouped[weekKey].start || !immutableWeeklyExercisesGrouped[weekKey].end">
{{ 'artemisApp.courseOverview.exerciseList.noDateAssociated' | artemisTranslate }}
</span>
<span
class="ms-2"
style="font-weight: 300"
jhiTranslate="artemisApp.courseOverview.exerciseList.exerciseGroupHeader"
[translateValues]="{ total: immutableWeeklyExercisesGrouped[weekKey].exercises.length }"
>
(Exercises: {{ immutableWeeklyExercisesGrouped[weekKey].exercises.length }})
</span>
</div>
<div *ngIf="!immutableWeeklyExercisesGrouped[weekKey].isCollapsed">
<jhi-course-exercise-row
class="pb-1"
[exercise]="exercise"
[course]="course"
[hasGuidedTour]="exercise === exerciseForGuidedTour"
*ngFor="let exercise of immutableWeeklyExercisesGrouped[weekKey].exercises"
/>
</div>
<div class="collapsed"></div>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Component, Input, OnChanges, OnInit } from '@angular/core';
import { Exercise } from 'app/entities/exercise.model';
import { ExerciseFilter, ExerciseWithDueDate } from 'app/overview/course-exercises/course-exercises.component';
import { Course } from 'app/entities/course.model';
import { faAngleDown, faAngleUp } from '@fortawesome/free-solid-svg-icons';
import { QuizExercise } from 'app/entities/quiz/quiz-exercise.model';
import { getAsMutableObject } from 'app/shared/util/utils';

@Component({
selector: 'jhi-course-exercises-grouped-by-week',
templateUrl: './course-exercises-grouped-by-week.component.html',
styleUrls: ['../course-overview.scss'],
})
export class CourseExercisesGroupedByWeekComponent implements OnInit, OnChanges {
@Input() nextRelevantExercise?: ExerciseWithDueDate;
@Input() course: Course;
@Input() exerciseForGuidedTour?: Exercise;
@Input() weeklyIndexKeys: string[];
@Input() immutableWeeklyExercisesGrouped: object;
@Input() activeFilters: Set<ExerciseFilter>;

weeklyExercisesGrouped: object;

faAngleUp = faAngleUp;
faAngleDown = faAngleDown;

ngOnInit() {
this.weeklyExercisesGrouped = getAsMutableObject(this.immutableWeeklyExercisesGrouped);
}

ngOnChanges() {
this.weeklyExercisesGrouped = getAsMutableObject(this.immutableWeeklyExercisesGrouped);
}

/**
* Checks whether an exercise is visible to students or not
* @param exercise The exercise which should be checked
*/
isVisibleToStudents(exercise: Exercise): boolean | undefined {
return !this.activeFilters.has(ExerciseFilter.UNRELEASED) || (exercise as QuizExercise)?.visibleToStudents;
}
}
Loading

0 comments on commit eca9020

Please sign in to comment.