diff --git a/package-lock.json b/package-lock.json index 9e5fdd1ef7..06415bae1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11200,6 +11200,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ diff --git a/src/app/common/entity-form/entity-form.component.ts b/src/app/common/entity-form/entity-form.component.ts index 5ebe626f8a..605ebd2473 100644 --- a/src/app/common/entity-form/entity-form.component.ts +++ b/src/app/common/entity-form/entity-form.component.ts @@ -5,6 +5,8 @@ import { EntityService } from 'ngx-entity-service'; import { Observable, tap } from 'rxjs'; import { Sort } from '@angular/material/sort'; import { MatTableDataSource } from '@angular/material/table'; +import { AppInjector } from 'src/app/app-injector'; +import { AlertService } from 'src/app/common/services/alert.service'; export type OnSuccessMethod = (object: T, isNew: boolean) => void; @@ -58,6 +60,10 @@ export abstract class EntityFormComponent implements AfterView ngAfterViewInit() {} + protected get dfAlertService(): AlertService { + return AppInjector.get(AlertService); + } + /** * Cancel edit of current selected value. */ @@ -138,14 +144,14 @@ export abstract class EntityFormComponent implements AfterView response = service.create(data, this.optionsOnRequest('create')); } else { // Nothing has changed if the selected value, so we want to inform the user - alertService.error( `${this.entityName} was not changed`, 6000); + this.dfAlertService.error(`${this.entityName} was not changed`, 6000); return; } // Handle the response response.subscribe({ next: (result: T) => { - alertService.success( `${this.entityName} saved`, 2000); + this.dfAlertService.success(`${this.entityName} saved`, 2000); // Success is implemented on all inheriting instances and is used // to handle the response appropriately for the context of the form success(result, this.selected ? false : true); @@ -163,7 +169,7 @@ export abstract class EntityFormComponent implements AfterView if (this.selected) { this.restoreFromBackup(); } - alertService.error( `${this.entityName} save failed: ${error}`, 6000); + this.dfAlertService.error(`${this.entityName} save failed: ${error}`, 6000); }, }); } else { diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index ae030bcc3e..78f0b9bb5f 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -135,6 +135,7 @@ import {environment} from 'src/environments/environment'; import {PickerModule} from '@ctrl/ngx-emoji-mart'; import {EmojiModule} from '@ctrl/ngx-emoji-mart/ngx-emoji'; import {EmojiService} from './common/services/emoji.service'; +import {StudentTaskListComponent} from './projects/states/dashboard/directives/student-task-list/student-task-list.component'; import {TaskListItemComponent} from './projects/states/dashboard/directives/student-task-list/task-list-item/task-list-item.component'; import {CreatePortfolioTaskListItemComponent} from './projects/states/dashboard/directives/student-task-list/create-portfolio-task-list-item/create-portfolio-task-list-item.component'; import {TaskDescriptionCardComponent} from './projects/states/dashboard/directives/task-dashboard/directives/task-description-card/task-description-card.component'; @@ -410,6 +411,7 @@ const GANTT_CHART_CONFIG = { UnitAnalyticsComponent, StudentTutorialSelectComponent, StudentCampusSelectComponent, + StudentTaskListComponent, TaskListItemComponent, CreatePortfolioTaskListItemComponent, TaskDescriptionCardComponent, diff --git a/src/app/doubtfire-angularjs.module.ts b/src/app/doubtfire-angularjs.module.ts index ca57426fd2..55d36dfa1d 100644 --- a/src/app/doubtfire-angularjs.module.ts +++ b/src/app/doubtfire-angularjs.module.ts @@ -64,7 +64,6 @@ import 'build/src/app/projects/states/groups/groups.js'; import 'build/src/app/projects/states/feedback/feedback.js'; import 'build/src/app/projects/states/states.js'; import 'build/src/app/projects/states/dashboard/directives/progress-dashboard/progress-dashboard.js'; -import 'build/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.js'; import 'build/src/app/projects/states/dashboard/directives/directives.js'; import 'build/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.js'; import 'build/src/app/projects/states/dashboard/dashboard.js'; @@ -160,6 +159,7 @@ import {WebcalService} from './api/services/webcal.service'; import {StudentTutorialSelectComponent} from './units/states/edit/directives/unit-students-editor/student-tutorial-select/student-tutorial-select.component'; import {StudentCampusSelectComponent} from './units/states/edit/directives/unit-students-editor/student-campus-select/student-campus-select.component'; import {EmojiService} from './common/services/emoji.service'; +import {StudentTaskListComponent} from './projects/states/dashboard/directives/student-task-list/student-task-list.component'; import {TaskListItemComponent} from './projects/states/dashboard/directives/student-task-list/task-list-item/task-list-item.component'; import {CreatePortfolioTaskListItemComponent} from './projects/states/dashboard/directives/student-task-list/create-portfolio-task-list-item/create-portfolio-task-list-item.component'; import {TaskDescriptionCardComponent} from './projects/states/dashboard/directives/task-dashboard/directives/task-description-card/task-description-card.component'; @@ -466,6 +466,10 @@ DoubtfireAngularJSModule.directive( 'taskListItem', downgradeComponent({component: TaskListItemComponent}), ); +DoubtfireAngularJSModule.directive( + 'studentTaskList', + downgradeComponent({component: StudentTaskListComponent}), +); DoubtfireAngularJSModule.directive( 'createPortfolioTaskListItem', downgradeComponent({component: CreatePortfolioTaskListItemComponent}), diff --git a/src/app/projects/states/dashboard/dashboard.coffee b/src/app/projects/states/dashboard/dashboard.coffee index d4d073c8ad..89633973ae 100644 --- a/src/app/projects/states/dashboard/dashboard.coffee +++ b/src/app/projects/states/dashboard/dashboard.coffee @@ -31,6 +31,12 @@ angular.module('doubtfire.projects.states.dashboard', [ setTaskAbbrAsUrlParams(task) } + # Ensure selection events from the Angular (downgraded) task list update the + # AngularJS scope, so `ng-if="taskData.selectedTask"` panels render. + listeners.push $scope.$on('StudentTaskSelected', (_event, task) -> + $scope.taskData.selectedTask = task + ) + # Sets URL parameters for the task key setTaskAbbrAsUrlParams = (task) -> taskAbbr = if _.isString(task) then task else task?.definition.abbreviation diff --git a/src/app/projects/states/dashboard/dashboard.tpl.html b/src/app/projects/states/dashboard/dashboard.tpl.html index e79f1af1ca..48b872845b 100644 --- a/src/app/projects/states/dashboard/dashboard.tpl.html +++ b/src/app/projects/states/dashboard/dashboard.tpl.html @@ -1,8 +1,8 @@
diff --git a/src/app/projects/states/dashboard/directives/directives.coffee b/src/app/projects/states/dashboard/directives/directives.coffee index 8ac3fe3ed8..ad3f6ba895 100644 --- a/src/app/projects/states/dashboard/directives/directives.coffee +++ b/src/app/projects/states/dashboard/directives/directives.coffee @@ -1,5 +1,4 @@ angular.module('doubtfire.projects.states.dashboard.directives', [ - 'doubtfire.projects.states.dashboard.directives.student-task-list' 'doubtfire.projects.states.dashboard.directives.progress-dashboard' 'doubtfire.projects.states.dashboard.directives.task-dashboard' ]) diff --git a/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.coffee b/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.coffee deleted file mode 100644 index b4212fa09b..0000000000 --- a/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.coffee +++ /dev/null @@ -1,62 +0,0 @@ -angular.module('doubtfire.projects.states.dashboard.directives.student-task-list', []) -# -# View a list of tasks -# -.directive('studentTaskList', -> - restrict: 'E' - templateUrl: 'projects/states/dashboard/directives/student-task-list/student-task-list.tpl.html' - scope: - project: '=' - # Function to invoke to refresh tasks - refreshTasks: '=?' - # Special taskData object (wraps the selectedTask) - taskData: '=' - controller: ($scope, $timeout, $filter, gradeService) -> - # Check taskSource exists - unless $scope.taskData? - throw Error "Invalid taskData provided. Must wrap the selectedTask and selectedTaskAbbr" - # Set up initial filtered tasks - $scope.filteredTasks = [] - # Set up filters - $scope.filters = { - taskName: null - } - # Sets new filteredTasks variable - applyFilters = -> - filteredTasks = $filter('tasksWithName')($scope.project.activeTasks(), $scope.filters.taskName) - $scope.filteredTasks = filteredTasks - $scope.showCreatePortfolio = !$scope.filters.taskName? || 'create portfolio'.indexOf($scope.filters.taskName.toLowerCase()) >= 0 - # Apply filters first-time - applyFilters() - # Sort the tasks according to priority. - $scope.project.calcTopTasks() - # When refreshing tasks, we are just reloading the active tasks - $scope.refreshTasks = applyFilters - # Expose grade service names - $scope.gradeNames = gradeService.grades - # On task name change, reapply filters - $scope.taskNameChanged = applyFilters - # UI call to change currently selected task - $scope.setSelectedTask = (task) -> - # Clicking on already selected task will disable that selection - task = null if $scope.isSelectedTask(task) - $scope.taskData.selectedTask = task - $scope.taskData.onSelectedTaskChange?(task) - scrollToTaskInList(task) if task? - scrollToTaskInList = (task) -> - taskEl = document.querySelector("##{task.taskKeyToIdString()}") - return unless taskEl? - funcName = if taskEl.scrollIntoViewIfNeeded? then 'scrollIntoViewIfNeeded' else if taskEl.scrollIntoView? then 'scrollIntoView' - return unless funcName? - taskEl[funcName]({behavior: 'smooth'}) - $timeout -> - scrollToTaskInList($scope.taskData.selectedTask) if $scope.taskData.selectedTask? - $scope.isSelectedTask = (task) -> - # Compare by definition - task.definition.id == $scope.taskData?.selectedTask?.definition.id - $scope.nearEnd = () -> - lateDate = new Date($scope.project.unit.endDate) # Get end date as date - lateDate.setDate(lateDate.getDate() - 21) # subtract 21 days - new Date() > lateDate - -) diff --git a/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.component.html b/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.component.html new file mode 100644 index 0000000000..e492a8cad7 --- /dev/null +++ b/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.component.html @@ -0,0 +1,42 @@ +
+
+ +
+
    +
  • + + +
  • + +
  • + + +
  • + +
  • + + +
  • +
  • + No tasks to display. +
  • +
+
diff --git a/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.component.scss b/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.component.scss new file mode 100644 index 0000000000..67d8b6ee7d --- /dev/null +++ b/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.component.scss @@ -0,0 +1,41 @@ +/* Task list component styles - inheriting from existing task-list styles */ + +/* Task item hover effects */ +.list-group-item-task { + cursor: pointer; + transition: background-color 0.2s ease; + position: relative; +} + +.list-group-item-task:hover { + background-color: #f5f5f5; +} + +.list-group-item-task.selected { + background-color: #e6f3ff; + border-color: #9ec0ff; +} + +/* Task data hover effects */ +.task-data h4:hover { + color: #337ab7; +} + +/* Ensure task item is fully clickable */ +.list-group-item-task .task-data, +.list-group-item-task .task-badges { + position: relative; + z-index: 1; +} + +/* Make sure badges don't block clicks */ +.task-badges { + pointer-events: none; +} + +/* Ensure entire task item can be clicked */ +.list-group-item-task { + min-height: 60px; + display: block; + width: 100%; +} diff --git a/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.component.ts b/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.component.ts new file mode 100644 index 0000000000..7f83d73028 --- /dev/null +++ b/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.component.ts @@ -0,0 +1,174 @@ +import { Component, Inject, Input, OnInit, Optional } from '@angular/core'; +import { GradeService } from 'src/app/common/services/grade.service'; + +@Component({ + selector: 'student-task-list', + templateUrl: './student-task-list.component.html', + styleUrls: ['./student-task-list.component.scss'], +}) +export class StudentTaskListComponent implements OnInit { + @Input() project: any; + @Input() taskData: any; + @Input() refreshTasks: any; + + filteredTasks: any[] = []; + filters: any = { + taskName: null + }; + showCreatePortfolio: boolean = false; + gradeNames: any; + + constructor( + private gradeService: GradeService, + @Optional() @Inject('$rootScope') private $rootScope?: any, + ) { + // Expose grade service names + this.gradeNames = this.gradeService.grades; + } + + ngOnInit() { + // Check taskData exists but don't throw error - handle gracefully + if (!this.taskData) { + this.taskData = { + selectedTask: null, + selectedTaskAbbr: null, + onSelectedTaskChange: null + }; + } + + // Sort the tasks according to priority + if (this.project) { + this.project.calcTopTasks(); + } + + // Apply filters first-time + this.applyFilters(); + + // Scroll to selected task after timeout + setTimeout(() => { + if (this.taskData?.selectedTask) { + this.scrollToTaskInList(this.taskData.selectedTask); + } + }); + } + + applyFilters() { + if (this.project && this.project.activeTasks) { + const allTasks = this.project.activeTasks(); + + // Use the tasksWithName filter (equivalent to AngularJS filter) + let filteredTasks = this.filterTasksByName(allTasks, this.filters.taskName); + + // Sort by topWeight (equivalent to orderBy: 'topWeight') + filteredTasks.sort((a, b) => (b.topWeight || 0) - (a.topWeight || 0)); + + this.filteredTasks = filteredTasks; + this.showCreatePortfolio = !this.filters.taskName || + 'create portfolio'.indexOf(this.filters.taskName.toLowerCase()) >= 0; + } else { + this.filteredTasks = []; + this.showCreatePortfolio = !this.filters.taskName || + 'create portfolio'.indexOf(this.filters.taskName.toLowerCase()) >= 0; + } + } + + filterTasksByName(tasks: any[], taskName: string): any[] { + if (!taskName || taskName.trim() === '') { + return tasks; + } + + const searchTerm = taskName.toLowerCase(); + return tasks.filter(task => + task.definition.name.toLowerCase().includes(searchTerm) || + task.definition.abbreviation.toLowerCase().includes(searchTerm) + ); + } + + taskNameChanged() { + this.applyFilters(); + } + + setSelectedTask(task: any) { + // Ensure taskData exists + if (!this.taskData) { + this.taskData = { + selectedTask: null, + selectedTaskAbbr: null, + onSelectedTaskChange: null + }; + } + + // Clicking on already selected task will disable that selection + if (this.isSelectedTask(task)) { + task = null; + } + + this.taskData.selectedTask = task; + + if (this.taskData.onSelectedTaskChange && typeof this.taskData.onSelectedTaskChange === 'function') { + this.taskData.onSelectedTaskChange(task); + } + + // Ensure the AngularJS dashboard state sees the selection even if input binding + // semantics differ between AJS/Angular templates. + try { + this.$rootScope?.$broadcast?.('StudentTaskSelected', task); + } catch { + // noop + } + + // This component is used inside an AngularJS template. Click handlers in Angular + // won't automatically trigger an AngularJS digest, so ensure the AJS `ng-if` + // blocks watching `taskData.selectedTask` update immediately. + this.triggerAngularJsDigest(); + + if (task) { + this.scrollToTaskInList(task); + } + } + + private triggerAngularJsDigest() { + try { + if (this.$rootScope?.$applyAsync) { + this.$rootScope.$applyAsync(); + return; + } + + // Fallback for cases where AngularJS services aren't exposed as injectables. + const ng = (globalThis as any)?.angular; + const $rootScope = ng?.element?.(document.body)?.injector?.()?.get?.('$rootScope'); + $rootScope?.$applyAsync?.(); + } catch { + // noop - app can run without angularjs injector in some contexts + } + } + + scrollToTaskInList(task: any) { + const taskEl = document.querySelector("#" + task.taskKeyToIdString()) as any; + if (!taskEl) return; + + const funcName = taskEl.scrollIntoViewIfNeeded ? 'scrollIntoViewIfNeeded' : + taskEl.scrollIntoView ? 'scrollIntoView' : null; + + if (funcName) { + taskEl[funcName]({behavior: 'smooth'}); + } + } + + isSelectedTask(task: any): boolean { + // Compare by definition + return task && this.taskData?.selectedTask && + task.definition.id === this.taskData.selectedTask.definition.id; + } + + nearEnd(): boolean { + if (!this.project || !this.project.unit) return false; + const lateDate = new Date(this.project.unit.endDate); // Get end date as date + lateDate.setDate(lateDate.getDate() - 21); // subtract 21 days + return new Date() > lateDate; + } + + trackByTaskId(index: number, task: any): any { + return task.id || task.definition.abbreviation; + } +} diff --git a/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.scss b/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.scss deleted file mode 100644 index 8cca67607c..0000000000 --- a/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.scss +++ /dev/null @@ -1,3 +0,0 @@ -student-task-list { - @include task-list(); -} diff --git a/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.tpl.html b/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.tpl.html deleted file mode 100644 index 93573d01c9..0000000000 --- a/src/app/projects/states/dashboard/directives/student-task-list/student-task-list.tpl.html +++ /dev/null @@ -1,84 +0,0 @@ -
-
- -
-
    -
  • - - -
  • - -
  • -
    -

    {{task.definition.name}}

    -

    - {{task.definition.abbreviation}} - - {{gradeNames[task.definition.targetGrade]}} Task - {{task.timeToStart()}} {{task.timeToDue()}} -

    -
    - -
    - -
    - - {{task.numNewComments}} - - - - -
    -
    - {{task.gradeDesc()}} - - {{task.qualityQts}}{{task.definition.maxQualityPts}} - - - - - - - - - ! - -
    -
    - -
  • - -
  • - - -
  • -
  • - No tasks to display. -
  • -
-
diff --git a/src/app/projects/states/dashboard/directives/student-task-list/task-list-item/task-list-item.component.scss b/src/app/projects/states/dashboard/directives/student-task-list/task-list-item/task-list-item.component.scss index bb60a29fd6..8cfd94d991 100644 --- a/src/app/projects/states/dashboard/directives/student-task-list/task-list-item/task-list-item.component.scss +++ b/src/app/projects/states/dashboard/directives/student-task-list/task-list-item/task-list-item.component.scss @@ -3,3 +3,20 @@ display: flex; padding-right: 15px; } + +.task-data { + flex: 1; + min-width: 0; +} + +.task-data h4, +.task-data p { + margin-bottom: 0; +} + +/* Keep the metadata line to a single row (2-line layout overall) */ +.task-data p { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.ts b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.ts index d7366dac8d..0d2ab8b1b6 100644 --- a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.ts +++ b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.ts @@ -166,7 +166,13 @@ export class TaskCommentsViewerComponent implements OnChanges, OnInit, OnDestroy this.feedbackTemplateService .query({contextType: 'task_definitions', contextId: task.definition.id}, {}) .subscribe({ - error: () => this.alerts.error('Error loading task feedback templates.'), + error: (err: any) => { + // Feedback templates are optional and may not exist for all tasks/contexts. + // Avoid flashing an error toast on expected "not found"/auth cases. + const status = err?.status; + if (status === 404 || status === 403) return; + this.alerts.error('Error loading task feedback templates.'); + }, }); } }