From 736a742d07b48064666c826535761095acac2070 Mon Sep 17 00:00:00 2001 From: Millicent Amolo Date: Thu, 26 Mar 2026 06:47:44 +1100 Subject: [PATCH 1/5] refactor: migrate unit portfolios state to Angular component Replace the legacy AngularJS portfolios controller/template with a downgraded Angular component. This keeps the route while moving tab/filter/grading behavior onto the Angular 17 path. Made-with: Cursor --- src/app/doubtfire-angular.module.ts | 2 + src/app/doubtfire-angularjs.module.ts | 2 + .../units/states/portfolios/portfolios.coffee | 112 ----- .../portfolios/portfolios.component.html | 336 ++++++++++++++ .../portfolios/portfolios.component.scss | 224 ++++++++++ .../states/portfolios/portfolios.component.ts | 412 ++++++++++++++++++ .../states/portfolios/portfolios.tpl.html | 274 +----------- 7 files changed, 977 insertions(+), 385 deletions(-) create mode 100644 src/app/units/states/portfolios/portfolios.component.html create mode 100644 src/app/units/states/portfolios/portfolios.component.scss create mode 100644 src/app/units/states/portfolios/portfolios.component.ts diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index 9284745988..044f361a3b 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -190,6 +190,7 @@ import {TaskAssessmentCardComponent} from './projects/states/dashboard/directive import {TaskSubmissionCardComponent} from './projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component'; import {TaskDashboardComponent} from './projects/states/dashboard/directives/task-dashboard/task-dashboard.component'; import {InboxComponent} from './units/states/tasks/inbox/inbox.component'; +import {PortfoliosComponent} from './units/states/portfolios/portfolios.component'; import {ProjectProgressBarComponent} from './common/project-progress-bar/project-progress-bar.component'; import {TeachingPeriodListComponent} from './admin/states/teaching-periods/teaching-period-list/teaching-period-list.component'; import {FChipComponent} from './common/f-chip/f-chip.component'; @@ -308,6 +309,7 @@ import {GradeService} from './common/services/grade.service'; TaskSubmissionCardComponent, TaskDashboardComponent, InboxComponent, + PortfoliosComponent, ProjectProgressBarComponent, TeachingPeriodListComponent, CreateNewUnitModal, diff --git a/src/app/doubtfire-angularjs.module.ts b/src/app/doubtfire-angularjs.module.ts index 868e381213..0289900c77 100644 --- a/src/app/doubtfire-angularjs.module.ts +++ b/src/app/doubtfire-angularjs.module.ts @@ -210,6 +210,7 @@ import {FooterComponent} from './common/footer/footer.component'; import {TaskAssessmentCardComponent} from './projects/states/dashboard/directives/task-dashboard/directives/task-assessment-card/task-assessment-card.component'; import {TaskSubmissionCardComponent} from './projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component'; import {InboxComponent} from './units/states/tasks/inbox/inbox.component'; +import {PortfoliosComponent} from './units/states/portfolios/portfolios.component'; import {TaskDefinitionEditorComponent} from './units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component'; import {UnitAnalyticsComponent} from './units/states/analytics/unit-analytics-route.component'; import {UnitTaskEditorComponent} from './units/states/edit/directives/unit-tasks-editor/unit-task-editor.component'; @@ -372,6 +373,7 @@ DoubtfireAngularJSModule.directive( downgradeComponent({component: TasksViewerComponent}), ); DoubtfireAngularJSModule.directive('fInbox', downgradeComponent({component: InboxComponent})); +DoubtfireAngularJSModule.directive('fPortfolios', downgradeComponent({component: PortfoliosComponent})); DoubtfireAngularJSModule.directive( 'fTaskDueCard', downgradeComponent({component: TaskDueCardComponent}), diff --git a/src/app/units/states/portfolios/portfolios.coffee b/src/app/units/states/portfolios/portfolios.coffee index ebbc09ec9b..86193d5628 100644 --- a/src/app/units/states/portfolios/portfolios.coffee +++ b/src/app/units/states/portfolios/portfolios.coffee @@ -7,121 +7,9 @@ angular.module('doubtfire.units.states.portfolios', []) parent: 'units/index' url: '/students/portfolios' templateUrl: "units/states/portfolios/portfolios.tpl.html" - controller: "UnitPortfoliosStateCtrl" data: task: "Student Portfolios" pageTitle: "_Home_" roleWhitelist: ['Tutor', 'Convenor', 'Admin', 'Auditor'] } ) -.controller("UnitPortfoliosStateCtrl", ($scope, alertService, analyticsService, gradeService, newProjectService, Visualisation, newTaskService, fileDownloaderService, newUserService) -> - # TODO: (@alexcu) Break this down into smaller directives/substates - - $scope.downloadGrades = -> fileDownloaderService.downloadFile($scope.unit.gradesUrl, "#{$scope.unit.code}-grades.csv") - $scope.downloadPortfolios = -> fileDownloaderService.downloadFile($scope.unit.portfoliosUrl, "#{$scope.unit.code}-portfolios.zip") - - $scope.studentFilter = 'allStudents' - $scope.portfolioFilter = 'withPortfolio' - - $scope.statusClass = newTaskService.statusClass - $scope.statusText = newTaskService.statusText - - refreshCharts = Visualisation.refreshAll - - # - # Sets the active tab - # - $scope.setActiveTab = (tab) -> - # Do nothing if we're switching to the same tab - return if tab is $scope.activeTab - $scope.activeTab?.active = false - $scope.activeTab = tab - $scope.activeTab.active = true - - if $scope.activeTab == $scope.tabs.viewProgress - refreshCharts() - - # - # Active task tab group - # - $scope.tabs = - selectStudent: - title: "Select Student" - subtitle: "Select the student to assess" - seq: 0 - viewProgress: - title: "View Progress" - subtitle: "See the progress of the student" - seq: 1 - viewPortfolio: - title: "View Portfolio" - subtitle: "See the portfolio of the student" - seq: 2 - assessPortfolio: - title: "Assess Portfolio" - subtitle: "Enter a grade for the student" - seq: 3 - - $scope.setActiveTab($scope.tabs.selectStudent) - - $scope.tutor = newUserService.currentUser - - $scope.search = "" - - # Pagination details - $scope.currentPage = 1 - $scope.maxSize = 5 - $scope.pageSize = 10 - - $scope.filterOptions = {selectedGrade: -1} - $scope.gradeValues = gradeService.gradeValues - $scope.grades = gradeService.grades - $scope.gradeAcronyms = gradeService.gradeAcronyms - - $scope.selectedStudent = null - - $scope.gradeResults = [ - { - name: 'Fail', - scores: [ 0, 10, 20, 30, 40, 44 ] - } - { - name: 'Pass', - scores: [ 50, 53, 55, 57 ] - } - { - name: 'Credit', - scores: [ 60, 63, 65, 67 ] - } - { - name: 'Distinction', - scores: [ 70, 73, 75, 77 ] - } - { - name: 'High Distinction', - scores: [ 80, 83, 85, 87 ] - } - { - name: 'High Distinction', - scores: [ 90, 93, 95, 97, 100 ] - } - ] - - $scope.editingRationale = false - - $scope.toggleEditRationale = -> - $scope.editingRationale = !$scope.editingRationale - - - analyticsService.watchEvent $scope, 'studentFilter', 'Teacher View - Grading Tab' - analyticsService.watchEvent $scope, 'sortOrder', 'Teacher View - Grading Tab' - analyticsService.watchEvent $scope, 'currentPage', 'Teacher View - Grading Tab', 'Selected Page' - - $scope.selectStudent = (student) -> - $scope.selectedStudent = student - $scope.project = null - newProjectService.loadProject(student, $scope.unit).subscribe({ - next: (project) -> $scope.project = project - error: (message) -> alertService.error( message, 6000) - }) -) diff --git a/src/app/units/states/portfolios/portfolios.component.html b/src/app/units/states/portfolios/portfolios.component.html new file mode 100644 index 0000000000..2941c685c5 --- /dev/null +++ b/src/app/units/states/portfolios/portfolios.component.html @@ -0,0 +1,336 @@ +
+ + + +
{{ tabs.selectStudent.title }}
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + All + + + + +
+ +
+ + Search + + + +
+
+ +
+

No portfolios found

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Student + {{ sortReverse ? '↓' : '↑' }} + + {{ row.student.studentId || row.student.username }} + + Name + {{ sortReverse ? '↓' : '↑' }} + + {{ row.student.name }} + + Tutor + + {{ row.tutorNames() }} + + Tutorial + + {{ row.shortTutorialDescription() }} + + Target + + + {{ gradeAcronym(row.targetGrade) }} + + + Submitted as + + + {{ gradeAcronym(row.submittedGrade) }} + + + Stats + +
+
+
+
Portfolio? + {{ (row.hasPortfolio || (row.portfolioStatus ?? 0) > 0) ? 'Yes' : 'No' }} + Grade + {{ row.grade ?? '-' }} +
+ + +
+
+
+ + + +
+ +

Loading student project…

+
+ +
+
+

Review Progress of {{ selectedStudent?.student.name }}

+

Progress through the unit's tasks.

+
+ +
+
+
Target Grade
+
+ +
+
+ +
+
Task Status Summary
+
+
+
+ + {{ bar.key }} + {{ bar.value }}% +
+
+
+
+
+
+
+
+
+
+
+ + + +
+ +

Loading student project…

+
+ +
+
+

Review Portfolio of {{ selectedStudent?.student.name }}

+

View or download portfolio for assessment.

+
+ +
+
+

No Portfolio Submitted

+
+ +
+ +
+
+
+
+
+ + + +
+ +

Loading student project…

+
+ +
+
+

Grade for {{ selectedStudent?.student.name }}

+

Assign grade and rationale for this work.

+
+ +
+ + Grade rationale + + + +
+
+
{{ bucket.name }}
+
+ +
+
+
+ +
+ +
+
+
+
+
+
+ +
+

Select student to view portfolio and assign grade.

+
+
+ diff --git a/src/app/units/states/portfolios/portfolios.component.scss b/src/app/units/states/portfolios/portfolios.component.scss new file mode 100644 index 0000000000..23d75663ff --- /dev/null +++ b/src/app/units/states/portfolios/portfolios.component.scss @@ -0,0 +1,224 @@ +#student-portfolios { + .toolbar { + margin-bottom: 1rem; + } + + .toolbar-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + justify-content: space-between; + } + + .filter-group { + display: flex; + gap: 0.25rem; + align-items: center; + } + + .search-row { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; + justify-content: space-between; + margin-top: 0.75rem; + } + + .download-btn { + height: 56px; + } + + .empty-state { + padding: 1.25rem 0; + text-align: center; + color: rgba(0, 0, 0, 0.6); + } + + table.f-table { + width: 100%; + } + + .clickable { + cursor: pointer; + user-select: none; + } + + .clickable-row { + cursor: pointer; + } + + .selected-row { + background-color: rgba(63, 81, 181, 0.1); // material primary-ish + } + + .sort-indicator { + font-size: 0.9em; + opacity: 0.75; + margin-left: 0.5rem; + } + + .grade-icon-circle { + width: 2.25em; + height: 2.25em; + border-radius: 100%; + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 100; + font-size: 0.95em; + } + + .grade-icon-circle.small { + width: 1.85em; + height: 1.85em; + font-size: 0.85em; + } + + .task-progress-stack { + width: 120px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + height: 12px; + border-radius: 999px; + overflow: hidden; + background-color: #eaeaea; + } + + .task-progress-segment { + height: 100%; + min-width: 1px; + } + + // Progress panel + .panel { + width: 100%; + padding: 1rem 0; + } + + .panel-header { + padding: 0.25rem 1rem; + } + + .panel-title { + margin: 0; + font-weight: 800; + font-size: 1.1rem; + } + + .panel-subtitle { + margin: 0.25rem 0 0; + opacity: 0.7; + } + + .panel-body { + padding: 0.25rem 1rem 1rem; + } + + .target-grade-title, + .progress-summary-title { + font-weight: 800; + margin-bottom: 0.5rem; + } + + .target-grade-options { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.25rem; + } + + .progress-summary-grid { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + .progress-summary-item { + padding: 0.25rem 0; + } + + .progress-summary-item-head { + display: flex; + align-items: center; + gap: 0.6rem; + } + + .status-dot { + width: 0.9rem; + height: 0.9rem; + border-radius: 999px; + } + + .status-label { + flex: 1; + text-transform: capitalize; + opacity: 0.85; + } + + .status-value { + font-variant-numeric: tabular-nums; + } + + .progress-line { + margin-top: 0.35rem; + height: 10px; + border-radius: 999px; + background-color: #f0f0f0; + overflow: hidden; + } + + .progress-line-fill { + height: 100%; + } + + // Assess panel + .alignment-rater { + display: flex; + flex-direction: column; + gap: 1.25rem; + } + + .grade-buckets { + display: flex; + flex-direction: column; + gap: 1rem; + } + + .grade-bucket { + display: flex; + gap: 0.75rem; + align-items: baseline; + flex-wrap: wrap; + } + + .grade-bucket-title { + width: 10rem; + font-weight: 800; + opacity: 0.9; + } + + .grade-bucket-scores { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + + .grade-selected { + background-color: rgba(63, 81, 181, 0.2); + } + + .actions { + display: flex; + justify-content: flex-end; + } + + .save-btn { + min-width: 8rem; + } +} + diff --git a/src/app/units/states/portfolios/portfolios.component.ts b/src/app/units/states/portfolios/portfolios.component.ts new file mode 100644 index 0000000000..6ce8703cf4 --- /dev/null +++ b/src/app/units/states/portfolios/portfolios.component.ts @@ -0,0 +1,412 @@ +import { Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild } from '@angular/core'; +import { MatPaginator, PageEvent } from '@angular/material/paginator'; +import { MatTableDataSource } from '@angular/material/table'; +import { GradeService } from 'src/app/common/services/grade.service'; +import { ProjectService } from 'src/app/api/services/project.service'; +import { Unit } from 'src/app/api/models/unit'; +import { Project } from 'src/app/api/models/project'; +import { UnitRole } from 'src/app/api/models/unit-role'; +import { User } from 'src/app/api/models/doubtfire-model'; +import { UserService } from 'src/app/api/services/user.service'; +import { FileDownloaderService } from 'src/app/common/file-downloader/file-downloader.service'; +import { AlertService } from 'src/app/common/services/alert.service'; +import { TaskService } from 'src/app/api/services/task.service'; +import { Subscription } from 'rxjs'; + +type StudentTabKey = 'selectStudent' | 'viewProgress' | 'viewPortfolio' | 'assessPortfolio'; + +type GradeBucket = { name: string; scores: number[] }; + +@Component({ + selector: 'f-portfolios', + templateUrl: './portfolios.component.html', + styleUrls: ['./portfolios.component.scss'], +}) +export class PortfoliosComponent implements OnInit, OnChanges, OnDestroy { + @Input() unit: Unit; + @Input() unitRole: UnitRole; + + @ViewChild(MatPaginator) paginator: MatPaginator; + + public tutor: User; + + public readonly tabs = { + selectStudent: { title: 'Select Student', subtitle: 'Select the student to assess', key: 'selectStudent' as StudentTabKey }, + viewProgress: { title: 'View Progress', subtitle: 'See the progress of the student', key: 'viewProgress' as StudentTabKey }, + viewPortfolio: { title: 'View Portfolio', subtitle: 'See the portfolio of the student', key: 'viewPortfolio' as StudentTabKey }, + assessPortfolio: { title: 'Assess Portfolio', subtitle: 'Enter a grade for the student', key: 'assessPortfolio' as StudentTabKey }, + }; + + public activeTab: StudentTabKey = 'selectStudent'; + + public studentFilter: 'allStudents' | 'myStudents' = 'allStudents'; + public portfolioFilter: 'withPortfolio' | 'allStudents' = 'withPortfolio'; + public filterOptions: { selectedGrade: number } = { selectedGrade: -1 }; + + public search = ''; + + public gradeValues: number[] = []; + public gradeServiceGrades: Record = {}; + public gradeResults: GradeBucket[] = []; + + public selectedStudent: Project | null = null; + public project: Project | null = null; + public loadingProject = false; + + public readonly dataSource = new MatTableDataSource([]); + + public displayedColumns: string[] = []; + public readonly baseDisplayedColumns: string[] = [ + 'student', + 'name', + 'tutor', + 'tutorial', + 'targetGrade', + 'submittedGrade', + 'progress', + 'grade', + ]; + public readonly hasPortfolioColumn = 'hasPortfolio'; + + public sortOrder: + | 'studentId' + | 'name' + | 'tutorNames' + | 'tutorial' + | 'targetGrade' + | 'submittedGrade' + | 'orderScale' + | 'hasPortfolio' + | 'grade' = 'name'; + public sortReverse = false; + + public pageSize = 10; + public pageSizeOptions = [5, 10, 25]; + public pageIndex = 0; + public totalItems = 0; + + private subscriptions: Subscription[] = []; + + private sortedRows: Project[] = []; + + constructor( + public gradeService: GradeService, + private projectService: ProjectService, + private userService: UserService, + private fileDownloader: FileDownloaderService, + private alerts: AlertService, + private taskService: TaskService, + ) {} + + ngOnInit(): void { + this.tutor = this.userService.currentUser; + + this.gradeValues = this.gradeService.gradeValues.slice(); + this.gradeServiceGrades = this.gradeService.grades as unknown as Record; + + // Matches the legacy template layout. + this.gradeResults = [ + { name: 'Fail', scores: [0, 10, 20, 30, 40, 44] }, + { name: 'Pass', scores: [50, 53, 55, 57] }, + { name: 'Credit', scores: [60, 63, 65, 67] }, + { name: 'Distinction', scores: [70, 73, 75, 77] }, + { name: 'High Distinction', scores: [80, 83, 85, 87] }, + { name: 'High Distinction', scores: [90, 93, 95, 97, 100] }, + ]; + + this.applyFiltersAndSort(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['unit'] || changes['unitRole']) { + this.applyFiltersAndSort(); + } + } + + ngOnDestroy(): void { + this.subscriptions.forEach((s) => s.unsubscribe()); + } + + private updateDisplayedColumns(): void { + const includeHasPortfolio = this.portfolioFilter === 'allStudents'; + this.displayedColumns = [...this.baseDisplayedColumns]; + if (includeHasPortfolio) { + // Keep order aligned with the legacy table. + this.displayedColumns = [ + 'student', + 'name', + 'tutor', + 'tutorial', + 'targetGrade', + 'submittedGrade', + 'progress', + 'hasPortfolio', + 'grade', + ]; + } else { + this.displayedColumns = [ + 'student', + 'name', + 'tutor', + 'tutorial', + 'targetGrade', + 'submittedGrade', + 'progress', + 'grade', + ]; + } + } + + public onTabChange(tabKey: StudentTabKey): void { + this.activeTab = tabKey; + if (tabKey === 'viewProgress' && this.project) { + // Ensure project stats are up-to-date when opening the progress panel. + this.project.refreshBurndownChartData(); + } + } + + public onTabChanged(index: number): void { + const tabKey: StudentTabKey = + index === 0 ? 'selectStudent' : index === 1 ? 'viewProgress' : index === 2 ? 'viewPortfolio' : 'assessPortfolio'; + this.onTabChange(tabKey); + } + + public onDownloadGrades(): void { + if (!this.unit) return; + this.fileDownloader.downloadFile(this.unit.gradesUrl, `${this.unit.code}-grades.csv`); + } + + public setActiveTab(tabKey: StudentTabKey): void { + if (this.activeTab === tabKey) return; + this.activeTab = tabKey; + this.onTabChange(tabKey); + } + + public selectStudent(student: Project): void { + this.selectedStudent = student; + this.project = null; + this.loadingProject = true; + + if (!this.unit || !this.selectedStudent) return; + + this.subscriptions.push( + this.projectService.loadProject(this.selectedStudent, this.unit).subscribe({ + next: (loaded) => { + this.project = loaded; + this.loadingProject = false; + // If the user already navigated to a tab that depends on the project, + // ensure derived visuals are refreshed after load. + if (this.activeTab === 'viewProgress') { + this.project.refreshBurndownChartData(); + } + }, + error: (message) => { + this.alerts.error(message, 6000); + this.loadingProject = false; + }, + }), + ); + } + + public trackByProjectId(_index: number, row: Project): number { + return row?.id; + } + + private normalizeSearchText(text: string): string { + return text?.trim().toLowerCase() ?? ''; + } + + private matchesSearch(project: Project): boolean { + const q = this.normalizeSearchText(this.search); + if (!q) return true; + return project.matches(q); + } + + private applyFiltersAndSort(): void { + if (!this.unit || !this.unit.students) { + this.dataSource.data = []; + this.totalItems = 0; + this.updateDisplayedColumns(); + return; + } + + let rows = this.unit.students.slice(); + + // Portfolio filter (legacy: hasPortfolio includes in-progress portfolios too). + rows = rows.filter((p) => { + if (this.portfolioFilter === 'allStudents') return true; + return (p.hasPortfolio || (p.portfolioStatus ?? 0) > 0) === true; + }); + + // Student filter. + rows = rows.filter((p) => { + if (this.studentFilter === 'allStudents') return true; + return p.hasTutor(this.tutor); + }); + + // Submitted grade filter. + rows = rows.filter((p) => { + if (this.filterOptions.selectedGrade === -1) return true; + return p.submittedGrade === this.filterOptions.selectedGrade; + }); + + // Search filter. + rows = rows.filter((p) => this.matchesSearch(p)); + + this.sortedRows = this.sortStudents(rows); + + this.totalItems = this.sortedRows.length; + this.updateDisplayedColumns(); + + this.pageIndex = 0; + this.repaginateFromSorted(); + } + + private sortStudents(rows: Project[]): Project[] { + const dir = this.sortReverse ? -1 : 1; + + const getSortValue = (p: Project): number | string => { + switch (this.sortOrder) { + case 'studentId': + return p.student?.studentId || p.student?.username || ''; + case 'name': + return p.student?.name || ''; + case 'tutorNames': + return p.tutorNames(); + case 'tutorial': + return p.shortTutorialDescription(); + case 'targetGrade': + return p.targetGrade ?? -1; + case 'submittedGrade': + return p.submittedGrade ?? -1; + case 'orderScale': + return p.orderScale ?? 0; + case 'hasPortfolio': + return (p.hasPortfolio || (p.portfolioStatus ?? 0) > 0) ? 1 : 0; + case 'grade': + return p.grade ?? -1; + } + }; + + return rows.sort((a, b) => { + const av = getSortValue(a); + const bv = getSortValue(b); + + if (typeof av === 'number' && typeof bv === 'number') { + return dir * (av - bv); + } + + return dir * String(av).localeCompare(String(bv)); + }); + } + + private repaginateFromSorted(): void { + const start = this.pageIndex * this.pageSize; + const end = start + this.pageSize; + this.dataSource.data = this.sortedRows.slice(start, end); + } + + public pageChanged(event: PageEvent): void { + this.pageIndex = event.pageIndex; + this.pageSize = event.pageSize; + this.repaginateFromSorted(); + } + + public toggleSort(order: typeof this.sortOrder): void { + if (this.sortOrder === order) { + this.sortReverse = !this.sortReverse; + } else { + this.sortOrder = order; + this.sortReverse = false; + } + this.applyFiltersAndSort(); + } + + public gradeAcronym(grade: number): string { + // -1..3 + return this.gradeService.gradeAcronyms[String(grade)] ?? 'G'; + } + + public gradeColor(grade: number): string { + return this.gradeService.gradeColors[String(grade)] ?? '#808080'; + } + + public taskSegmentColor(taskStatusKey: string): string { + return this.taskService.statusColors.get(taskStatusKey as any) ?? '#CCCCCC'; + } + + public taskProgressSegments(taskStats: { key: string; value: number }[] | undefined): { key: string; value: number }[] { + if (!taskStats) return []; + // Keep legacy behaviour: hide tiny segments when rendering labels. + return taskStats.filter((bar) => bar.value !== undefined); + } + + public downloadPortfolios(): void { + // Intentionally left out: legacy button was commented out. + } + + public setTargetGrade(grade: number): void { + if (!this.project) return; + const next = grade; + if (this.project.targetGrade === next) return; + + this.project.targetGrade = next; + this.projectService.update(this.project).subscribe({ + next: () => { + this.project.refreshBurndownChartData(); + }, + error: (message) => { + this.alerts.error(message, 6000); + }, + }); + } + + public saveGrade(): void { + if (!this.project) return; + const score = this.project.grade; + if (score == null) return; + if (!this.project.gradeRationale?.trim()) return; + + this.project.assignGrade(score, this.project.gradeRationale); + this.project.refreshBurndownChartData(); + } + + public assignGradeAndRefresh(score: number): void { + if (!this.project) return; + if (!this.project.gradeRationale?.trim()) return; + this.project.assignGrade(score, this.project.gradeRationale); + this.project.refreshBurndownChartData(); + } + + public get showNoStudentMessage(): boolean { + return this.activeTab !== 'selectStudent' && !this.selectedStudent; + } + + public get canShowProgressAndPortfolio(): boolean { + return !!this.project && !!this.selectedStudent; + } + + public get selectedStudentLabel(): string { + return this.selectedStudent?.student?.name ?? ''; + } + + public onSearchChange(): void { + this.applyFiltersAndSort(); + } + + public onPortfolioFilterChange(value: 'withPortfolio' | 'allStudents'): void { + this.portfolioFilter = value; + this.applyFiltersAndSort(); + } + + public onStudentFilterChange(value: 'allStudents' | 'myStudents'): void { + this.studentFilter = value; + this.applyFiltersAndSort(); + } + + public onSubmittedGradeChange(value: number): void { + this.filterOptions.selectedGrade = value; + this.applyFiltersAndSort(); + } +} + diff --git a/src/app/units/states/portfolios/portfolios.tpl.html b/src/app/units/states/portfolios/portfolios.tpl.html index 51f09b077c..970bd7f72c 100644 --- a/src/app/units/states/portfolios/portfolios.tpl.html +++ b/src/app/units/states/portfolios/portfolios.tpl.html @@ -1,274 +1,2 @@ -
- - - {{tab.title}} - - - -
-
-
-

Mark Portfolios

- Assess student portfolios -
-
-
-
- - -
-
- - -
-
- -
-
- -
-
- - - -
- -
- -
-
- -

No portfolios found

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - Student - - - - Name - - - - Tutor - - - - Tutorial - - - - - Target - - - - Submitted as - - - - - Stats - - - - Portfolio? - - - - Grade - -
{{student.student.studentId || student.student.username}}{{student.student.name}}{{student.tutorNames()}}{{student.shortTutorialDescription()}} - - - - - - - {{bar.value}}% - - - {{student.hasPortfolio ? "Yes" : "No"}}{{student.grade}}
-
- -
-
-
-
-

Portfolio Details

- Review portfolio and assign grade. -
-
-
- -

Select student to view portfolio and assign grade

-
-
-
-
-
-
-

Review Progress of {{selectedStudent.student.name}}

- Review the students progress through the unit's tasks. -
-
-
- -
-
+ -
-
-
-

Review Portfolio of {{selectedStudent.student.name}}

- View or download portfolio for assessment. -
-
-
- -

No Portfolio Submitted

-
- -
- -
-
-
-

Grade for {{selectedStudent.student.name}}

- Assign Grade for this work. -
-
-
-
- -
- -
-
{{results.name}}
-

- -

-
-
- -
-
-
-
-
From ee4bb4141dd94fc61040073ca91d1fdee7c410c3 Mon Sep 17 00:00:00 2001 From: Millicent Amolo Date: Fri, 3 Apr 2026 01:56:34 +1100 Subject: [PATCH 2/5] fix: add missing portfolios page options and filtered count --- .../states/portfolios/portfolios.component.html | 10 ++++++++++ .../states/portfolios/portfolios.component.scss | 12 ++++++++++++ .../units/states/portfolios/portfolios.component.ts | 11 +++++++++++ 3 files changed, 33 insertions(+) diff --git a/src/app/units/states/portfolios/portfolios.component.html b/src/app/units/states/portfolios/portfolios.component.html index 2941c685c5..f71f731681 100644 --- a/src/app/units/states/portfolios/portfolios.component.html +++ b/src/app/units/states/portfolios/portfolios.component.html @@ -64,10 +64,16 @@ Search +
+ +
@@ -75,6 +81,10 @@

No portfolios found

+

+ Showing {{ totalItems }} of {{ totalEnrolledStudents }} students enrolled. +

+
diff --git a/src/app/units/states/portfolios/portfolios.component.scss b/src/app/units/states/portfolios/portfolios.component.scss index 23d75663ff..2be0664c9f 100644 --- a/src/app/units/states/portfolios/portfolios.component.scss +++ b/src/app/units/states/portfolios/portfolios.component.scss @@ -26,10 +26,22 @@ margin-top: 0.75rem; } + .toolbar-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + justify-content: flex-end; + } + .download-btn { height: 56px; } + .filtered-count-text { + margin: 0.25rem 0 0.75rem; + color: rgba(0, 0, 0, 0.65); + } + .empty-state { padding: 1.25rem 0; text-align: center; diff --git a/src/app/units/states/portfolios/portfolios.component.ts b/src/app/units/states/portfolios/portfolios.component.ts index 6ce8703cf4..1b83e255f7 100644 --- a/src/app/units/states/portfolios/portfolios.component.ts +++ b/src/app/units/states/portfolios/portfolios.component.ts @@ -84,6 +84,7 @@ export class PortfoliosComponent implements OnInit, OnChanges, OnDestroy { public pageSizeOptions = [5, 10, 25]; public pageIndex = 0; public totalItems = 0; + public totalEnrolledStudents = 0; private subscriptions: Subscription[] = []; @@ -176,6 +177,11 @@ export class PortfoliosComponent implements OnInit, OnChanges, OnDestroy { this.fileDownloader.downloadFile(this.unit.gradesUrl, `${this.unit.code}-grades.csv`); } + public onDownloadPortfolios(): void { + if (!this.unit) return; + this.fileDownloader.downloadFile(this.unit.portfoliosUrl, `${this.unit.code}-portfolios.zip`); + } + public setActiveTab(tabKey: StudentTabKey): void { if (this.activeTab === tabKey) return; this.activeTab = tabKey; @@ -231,6 +237,7 @@ export class PortfoliosComponent implements OnInit, OnChanges, OnDestroy { } let rows = this.unit.students.slice(); + this.totalEnrolledStudents = this.unit.students.length; // Portfolio filter (legacy: hasPortfolio includes in-progress portfolios too). rows = rows.filter((p) => { @@ -390,6 +397,10 @@ export class PortfoliosComponent implements OnInit, OnChanges, OnDestroy { return this.selectedStudent?.student?.name ?? ''; } + public get showFilteredCountText(): boolean { + return this.totalItems > 0 && this.totalItems < this.totalEnrolledStudents; + } + public onSearchChange(): void { this.applyFiltersAndSort(); } From 7b42daea56459a95114bea6863cbef00fdd23882 Mon Sep 17 00:00:00 2001 From: Millicent Amolo Date: Sat, 4 Apr 2026 18:14:44 +1100 Subject: [PATCH 3/5] fix(portfolios): restore legacy progress dashboard and lazy-load select tab --- src/app/doubtfire-angular.module.ts | 2 + .../project-progress-dashboard.coffee | 43 +++++++++----- ...js-project-progress-dashboard.component.ts | 32 ++++++++++ .../portfolios/portfolios.component.html | 49 ++++------------ .../portfolios/portfolios.component.scss | 58 +------------------ .../states/portfolios/portfolios.component.ts | 29 ++-------- 6 files changed, 82 insertions(+), 131 deletions(-) create mode 100644 src/app/units/states/portfolios/ajs-project-progress-dashboard.component.ts diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index d15f9c1f1c..6d64ef6f61 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -214,6 +214,7 @@ import {TaskSubmissionCardComponent} from './projects/states/dashboard/directive import {TaskDashboardComponent} from './projects/states/dashboard/directives/task-dashboard/task-dashboard.component'; import {InboxComponent} from './units/states/tasks/inbox/inbox.component'; import {PortfoliosComponent} from './units/states/portfolios/portfolios.component'; +import {AjsProjectProgressDashboardComponent} from './units/states/portfolios/ajs-project-progress-dashboard.component'; import {ProjectProgressBarComponent} from './common/project-progress-bar/project-progress-bar.component'; import {TeachingPeriodListComponent} from './admin/states/teaching-periods/teaching-period-list/teaching-period-list.component'; import {FChipComponent} from './common/f-chip/f-chip.component'; @@ -456,6 +457,7 @@ const GANTT_CHART_CONFIG = { TaskDashboardComponent, InboxComponent, PortfoliosComponent, + AjsProjectProgressDashboardComponent, ProjectProgressBarComponent, TeachingPeriodListComponent, CreateNewUnitModal, diff --git a/src/app/projects/project-progress-dashboard/project-progress-dashboard.coffee b/src/app/projects/project-progress-dashboard/project-progress-dashboard.coffee index d28a0d31b5..fcf826733d 100644 --- a/src/app/projects/project-progress-dashboard/project-progress-dashboard.coffee +++ b/src/app/projects/project-progress-dashboard/project-progress-dashboard.coffee @@ -10,17 +10,31 @@ angular.module('doubtfire.projects.project-progress-dashboard',[]) .directive('projectProgressDashboard', -> restrict: 'E' templateUrl: 'projects/project-progress-dashboard/project-progress-dashboard.tpl.html' + # Explicit bindings for use inside upgraded Angular hosts (f-portfolios). Link copies from + # $parent when attributes are omitted so legacy templates without project="..." still work. + scope: + project: '<' + unit: '<' controller: ($scope, $state, $rootScope, $stateParams, newProjectService, alertService, gradeService, newTaskService, listenerService) -> - if $stateParams.projectId? - $scope.studentProjectId = $stateParams.projectId - else if $scope.project? - $scope.studentProjectId = $scope.project.id - $scope.grades = gradeService.grades $scope.currentVisualisation = 'burndown' + $scope.taskStats = {} + + updateTaskCompletionStats = -> + return unless $scope.project? + $scope.taskStats.numberOfTasksCompleted = $scope.project.tasksByStatus(newTaskService.completeStatus).length + $scope.taskStats.numberOfTasksRemaining = $scope.project.activeTasks().length - $scope.taskStats.numberOfTasksCompleted + + syncStudentProjectId = -> + if $stateParams.projectId? + $scope.studentProjectId = $stateParams.projectId + else if $scope.project? + $scope.studentProjectId = $scope.project.id + $scope.chooseGrade = (idx) -> + return unless $scope.project? $scope.project.targetGrade = idx newProjectService.update($scope.project).subscribe( (response) -> @@ -29,18 +43,19 @@ angular.module('doubtfire.projects.project-progress-dashboard',[]) updateTaskCompletionStats() $scope.taskCount = -> - $scope.unit.taskDefinitionCount - - $scope.taskStats = {} - - # Update move to task and project... - updateTaskCompletionStats = -> - $scope.taskStats.numberOfTasksCompleted = $scope.project.tasksByStatus(newTaskService.completeStatus).length - $scope.taskStats.numberOfTasksRemaining = $scope.project.activeTasks().length - $scope.taskStats.numberOfTasksCompleted + $scope.unit?.taskDefinitionCount $scope.$on 'TaskStatusUpdated', -> updateTaskCompletionStats() + $scope.$watch 'project', (project) -> + return unless project? + syncStudentProjectId() + updateTaskCompletionStats() - updateTaskCompletionStats() + link: (scope, _el, _attrs) -> + unless scope.project? + scope.project = scope.$parent.project if scope.$parent? + unless scope.unit? + scope.unit = scope.$parent.unit if scope.$parent? ) diff --git a/src/app/units/states/portfolios/ajs-project-progress-dashboard.component.ts b/src/app/units/states/portfolios/ajs-project-progress-dashboard.component.ts new file mode 100644 index 0000000000..e71e55855e --- /dev/null +++ b/src/app/units/states/portfolios/ajs-project-progress-dashboard.component.ts @@ -0,0 +1,32 @@ +import { Component, ElementRef, Inject, Input, Injector, OnInit, Optional } from '@angular/core'; +import { UpgradeComponent } from '@angular/upgrade/static'; +import { Project } from 'src/app/api/models/project'; +import { Unit } from 'src/app/api/models/unit'; +import { visualisations } from 'src/app/ajs-upgraded-providers'; + +/** + * Hosts the AngularJS `projectProgressDashboard` directive inside Angular templates + * (same charts and target-grade UI as the legacy portfolios “View Progress” tab). + */ +@Component({ + selector: 'f-ajs-project-progress-dashboard', + template: '', +}) +export class AjsProjectProgressDashboardComponent extends UpgradeComponent implements OnInit { + @Input() project: Project; + @Input() unit: Unit; + + constructor( + elementRef: ElementRef, + injector: Injector, + @Optional() @Inject(visualisations) private readonly visualisationApi: { refreshAll?: () => void } | null, + ) { + super('projectProgressDashboard', elementRef, injector); + } + + override ngOnInit(): void { + super.ngOnInit(); + // Burndown / pie use legacy visualisation lifecycle; match UnitPortfoliosStateCtrl.refreshCharts. + setTimeout(() => this.visualisationApi?.refreshAll?.(), 0); + } +} diff --git a/src/app/units/states/portfolios/portfolios.component.html b/src/app/units/states/portfolios/portfolios.component.html index f71f731681..797aa27e76 100644 --- a/src/app/units/states/portfolios/portfolios.component.html +++ b/src/app/units/states/portfolios/portfolios.component.html @@ -8,6 +8,7 @@
{{ tabs.selectStudent.title }}
+
@@ -196,6 +197,7 @@ >
+
@@ -205,47 +207,18 @@

Loading student project…

-
-
-

Review Progress of {{ selectedStudent?.student.name }}

-

Progress through the unit's tasks.

-
- -
-
-
Target Grade
-
- -
+
+
+

Review Progress of {{ selectedStudent?.student.name }}

+

Progress through the unit's tasks.

- -
-
Task Status Summary
-
-
-
- - {{ bar.key }} - {{ bar.value }}% -
-
-
-
-
-
+
+
-
diff --git a/src/app/units/states/portfolios/portfolios.component.scss b/src/app/units/states/portfolios/portfolios.component.scss index 2be0664c9f..05a39738bf 100644 --- a/src/app/units/states/portfolios/portfolios.component.scss +++ b/src/app/units/states/portfolios/portfolios.component.scss @@ -131,61 +131,9 @@ padding: 0.25rem 1rem 1rem; } - .target-grade-title, - .progress-summary-title { - font-weight: 800; - margin-bottom: 0.5rem; - } - - .target-grade-options { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - margin-top: 0.25rem; - } - - .progress-summary-grid { - display: flex; - flex-direction: column; - gap: 0.75rem; - } - - .progress-summary-item { - padding: 0.25rem 0; - } - - .progress-summary-item-head { - display: flex; - align-items: center; - gap: 0.6rem; - } - - .status-dot { - width: 0.9rem; - height: 0.9rem; - border-radius: 999px; - } - - .status-label { - flex: 1; - text-transform: capitalize; - opacity: 0.85; - } - - .status-value { - font-variant-numeric: tabular-nums; - } - - .progress-line { - margin-top: 0.35rem; - height: 10px; - border-radius: 999px; - background-color: #f0f0f0; - overflow: hidden; - } - - .progress-line-fill { - height: 100%; + .progress-panel-body { + overflow-x: auto; + padding-top: 0.5rem; } // Assess panel diff --git a/src/app/units/states/portfolios/portfolios.component.ts b/src/app/units/states/portfolios/portfolios.component.ts index 1b83e255f7..5ad7b265dd 100644 --- a/src/app/units/states/portfolios/portfolios.component.ts +++ b/src/app/units/states/portfolios/portfolios.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, ViewChild } from '@angular/core'; +import { Component, Inject, Input, OnChanges, OnDestroy, OnInit, Optional, SimpleChanges, ViewChild } from '@angular/core'; import { MatPaginator, PageEvent } from '@angular/material/paginator'; import { MatTableDataSource } from '@angular/material/table'; import { GradeService } from 'src/app/common/services/grade.service'; @@ -12,6 +12,7 @@ import { FileDownloaderService } from 'src/app/common/file-downloader/file-downl import { AlertService } from 'src/app/common/services/alert.service'; import { TaskService } from 'src/app/api/services/task.service'; import { Subscription } from 'rxjs'; +import { visualisations } from 'src/app/ajs-upgraded-providers'; type StudentTabKey = 'selectStudent' | 'viewProgress' | 'viewPortfolio' | 'assessPortfolio'; @@ -97,6 +98,7 @@ export class PortfoliosComponent implements OnInit, OnChanges, OnDestroy { private fileDownloader: FileDownloaderService, private alerts: AlertService, private taskService: TaskService, + @Optional() @Inject(visualisations) private readonly visualisationApi: { refreshAll?: () => void } | null, ) {} ngOnInit(): void { @@ -161,8 +163,9 @@ export class PortfoliosComponent implements OnInit, OnChanges, OnDestroy { public onTabChange(tabKey: StudentTabKey): void { this.activeTab = tabKey; if (tabKey === 'viewProgress' && this.project) { - // Ensure project stats are up-to-date when opening the progress panel. this.project.refreshBurndownChartData(); + // Legacy charts (nv/d3) need a refresh after the tab body is shown. + setTimeout(() => this.visualisationApi?.refreshAll?.(), 0); } } @@ -342,32 +345,10 @@ export class PortfoliosComponent implements OnInit, OnChanges, OnDestroy { return this.taskService.statusColors.get(taskStatusKey as any) ?? '#CCCCCC'; } - public taskProgressSegments(taskStats: { key: string; value: number }[] | undefined): { key: string; value: number }[] { - if (!taskStats) return []; - // Keep legacy behaviour: hide tiny segments when rendering labels. - return taskStats.filter((bar) => bar.value !== undefined); - } - public downloadPortfolios(): void { // Intentionally left out: legacy button was commented out. } - public setTargetGrade(grade: number): void { - if (!this.project) return; - const next = grade; - if (this.project.targetGrade === next) return; - - this.project.targetGrade = next; - this.projectService.update(this.project).subscribe({ - next: () => { - this.project.refreshBurndownChartData(); - }, - error: (message) => { - this.alerts.error(message, 6000); - }, - }); - } - public saveGrade(): void { if (!this.project) return; const score = this.project.grade; From 084d5579ae8323fc55961a3ca4fc3f545b1d444d Mon Sep 17 00:00:00 2001 From: Millicent Amolo Date: Sat, 4 Apr 2026 23:48:04 +1100 Subject: [PATCH 4/5] fix(angular): remove invalid MatChipListbox from NgModule imports MatChipListbox is not an NgModule; MatChipsModule already provides chip listbox. Portfolios: migrate state to Angular component shell, drop duplicate legacy template/controller wiring from portfolios.tpl.html and portfolios.coffee. Made-with: Cursor --- src/app/doubtfire-angular.module.ts | 3 +- .../units/states/portfolios/portfolios.coffee | 144 ------- .../portfolios/portfolios.component.html | 20 + .../portfolios/portfolios.component.scss | 10 + .../states/portfolios/portfolios.component.ts | 31 +- .../states/portfolios/portfolios.tpl.html | 357 ------------------ 6 files changed, 59 insertions(+), 506 deletions(-) diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index 6d64ef6f61..4df00b3cd4 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -36,7 +36,7 @@ import {MatSnackBarModule} from '@angular/material/snack-bar'; import {MatPaginatorModule} from '@angular/material/paginator'; import {MatTooltipModule} from '@angular/material/tooltip'; import {MatSlideToggleModule} from '@angular/material/slide-toggle'; -import {MatChipListbox, MatChipsModule} from '@angular/material/chips'; +import {MatChipsModule} from '@angular/material/chips'; import {MatGridListModule} from '@angular/material/grid-list'; import {PdfViewerModule} from 'ng2-pdf-viewer'; import {UIRouterUpgradeModule} from '@uirouter/angular-hybrid'; @@ -687,7 +687,6 @@ const GANTT_CHART_CONFIG = { NgxGanttModule, MatSidenavModule, MonacoEditorModule.forRoot(), - MatChipListbox, ], }) diff --git a/src/app/units/states/portfolios/portfolios.coffee b/src/app/units/states/portfolios/portfolios.coffee index 0699d79066..86193d5628 100644 --- a/src/app/units/states/portfolios/portfolios.coffee +++ b/src/app/units/states/portfolios/portfolios.coffee @@ -13,147 +13,3 @@ angular.module('doubtfire.units.states.portfolios', []) roleWhitelist: ['Tutor', 'Convenor', 'Admin', 'Auditor'] } ) -.controller("UnitPortfoliosStateCtrl", ($scope, alertService, analyticsService, gradeService, newProjectService, Visualisation, newTaskService, fileDownloaderService, newUserService, D2lTransferModal, newUnitService, sidekiqProgressModalService) -> - # TODO: (@alexcu) Break this down into smaller directives/substates - - $scope.unit.loadD2lMapping().subscribe() - - $scope.downloadGrades = -> fileDownloaderService.downloadFile($scope.unit.gradesUrl, "#{$scope.unit.code}-grades.csv") - - $scope.downloadPortfolios = -> - newUnitService.zipPortfolios($scope.unit).subscribe({ - next: (newJob) -> - sidekiqProgressModalService.show("Downloading Portfolios: " + $scope.unit.code, newJob.id).subscribe({ - next: (job) -> - fileDownloaderService.downloadFile($scope.unit.portfoliosUrl, "#{$scope.unit.code}-portfolios.zip") - error: (message) -> alertService.error(message, 6000) - }) - error: (message) -> alertService.error(message, 6000) - }) - - - $scope.studentFilter = 'allStudents' - $scope.portfolioFilter = 'withPortfolio' - - $scope.statusClass = newTaskService.statusClass - $scope.statusText = newTaskService.statusText - - refreshCharts = Visualisation.refreshAll - - # - # Sets the active tab - # - $scope.setActiveTab = (tab) -> - # Do nothing if we're switching to the same tab - return if tab is $scope.activeTab - $scope.activeTab?.active = false - $scope.activeTab = tab - $scope.activeTab.active = true - - if $scope.activeTab == $scope.tabs.viewProgress - refreshCharts() - - # - # Active task tab group - # - $scope.tabs = - selectStudent: - title: "Select Student" - subtitle: "Select the student to assess" - seq: 0 - viewProgress: - title: "View Progress" - subtitle: "See the progress of the student" - seq: 1 - viewStaffNotes: - title: "View Staff Notes" - subtitle: "See notes of the student add by staff" - seq: 2 - viewPortfolio: - title: "View Portfolio" - subtitle: "See the portfolio of the student" - seq: 3 - assessPortfolio: - title: "Assess Portfolio" - subtitle: "Enter a grade for the student" - seq: 4 - - $scope.setActiveTab($scope.tabs.selectStudent) - - $scope.tutor = newUserService.currentUser - - $scope.search = "" - - # Pagination details - $scope.currentPage = 1 - $scope.maxSize = 5 - $scope.pageSize = 10 - - $scope.filterOptions = {selectedGrade: -1} - $scope.gradeValues = gradeService.gradeValues - $scope.grades = gradeService.grades - $scope.gradeAcronyms = gradeService.gradeAcronyms - - $scope.selectedStudent = null - - $scope.gradeResults = [ - { - name: 'Fail', - scores: [ 0, 10, 20, 30, 40, 44 ] - } - { - name: 'Pass', - scores: [ 50, 53, 55, 57 ] - } - { - name: 'Credit', - scores: [ 60, 63, 65, 67 ] - } - { - name: 'Distinction', - scores: [ 70, 73, 75, 77 ] - } - { - name: 'High Distinction', - scores: [ 80, 83, 85, 87 ] - } - { - name: 'High Distinction', - scores: [ 90, 93, 95, 97, 100 ] - } - ] - - $scope.editingRationale = false - - $scope.toggleEditRationale = -> - $scope.editingRationale = !$scope.editingRationale - - - analyticsService.watchEvent $scope, 'studentFilter', 'Teacher View - Grading Tab' - analyticsService.watchEvent $scope, 'sortOrder', 'Teacher View - Grading Tab' - analyticsService.watchEvent $scope, 'currentPage', 'Teacher View - Grading Tab', 'Selected Page' - - $scope.selectStudent = (student) -> - $scope.selectedStudent = student - $scope.project = null - newProjectService.loadProject(student, $scope.unit).subscribe({ - next: (project) -> - $scope.project = project - $scope.project.preloadedUrl = $scope.project.portfolioUrl() - error: (message) -> alertService.error( message, 6000) - }) - - $scope.hasD2lMapping = -> - $scope.unit.hasD2lMapping() - - $scope.transferToD2L = -> - D2lTransferModal.open($scope.unit) - - $scope.openProject = ($event, project) -> - $event.stopPropagation() - # HACK: avoids using window.open() to prevent AngularJS error - link = document.createElement('a') - link.href = "/projects/#{project.id}/dashboard/?tutor=true" - link.target = '_blank' - link.click() -) diff --git a/src/app/units/states/portfolios/portfolios.component.html b/src/app/units/states/portfolios/portfolios.component.html index 797aa27e76..71316507f2 100644 --- a/src/app/units/states/portfolios/portfolios.component.html +++ b/src/app/units/states/portfolios/portfolios.component.html @@ -66,6 +66,7 @@
+
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - Student - - - - - Name - - - - - Tutor - - - - - Tutorial - - - - - Target - - - - - Submitted as - - - - - Submission Date - - - - - Stats - - - - Portfolio? - - - - - Grade - - -
{{student.student.studentId || student.student.username}}{{student.student.name}}{{student.tutorNames()}}{{student.shortTutorialDescription()}} - - - - {{student.portfolioSubmissionDate | date: 'EEE d MMM y, h:mm a'}} - - - {{bar.value}}% - - - - {{student.hasPortfolio ? "Yes" : "No"}} - {{student.grade}} - -
-
- - -
-
-
-

Portfolio Details

- Review portfolio and assign grade. -
-
-
- -

Select student to view portfolio and assign grade

-
-
-
-
-
-
-

Review Progress of {{selectedStudent.student.name}}

- Review the students progress through the unit's tasks. -
-
-
- -
-
- -
-
-
-

Review Staff Notes for {{selectedStudent.student.name}}

- View internal notes recorded by staff about the student. -
-
-
- -
-
- -
-
-
-

Review Portfolio of {{selectedStudent.student.name}}

- View or download portfolio for assessment. -
-
-
- -

No Portfolio Submitted

-
-
- - -
-
- -
-
-
-

Grade for {{selectedStudent.student.name}}

- Assign Grade for this work. -
-
-
-
- -
- -
-
{{results.name}}
-

- -

-
-
- -
-
-
-
- From b3380de6de8a0c7238685545f5f3caa440bb5c44 Mon Sep 17 00:00:00 2001 From: Millicent Amolo Date: Sun, 5 Apr 2026 02:31:58 +1000 Subject: [PATCH 5/5] chore(portfolios): apply updates to migration changes --- .../states/index/global-state.service.ts | 31 +++++++++++-------- .../states/sign-in/sign-in.component.ts | 3 +- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/app/projects/states/index/global-state.service.ts b/src/app/projects/states/index/global-state.service.ts index 6d3cdb12d2..c28824121b 100644 --- a/src/app/projects/states/index/global-state.service.ts +++ b/src/app/projects/states/index/global-state.service.ts @@ -2,7 +2,7 @@ import {Inject, Injectable, OnDestroy} from '@angular/core'; import {MediaObserver} from 'ng-flex-layout'; import {UIRouter} from '@uirouter/angular'; import {EntityCache} from 'ngx-entity-service'; -import {BehaviorSubject, Observable, Subject, skip, take} from 'rxjs'; +import {BehaviorSubject, Observable, Subject, catchError, of, skip, take} from 'rxjs'; import { CampusService, LearningOutcomeService, @@ -122,9 +122,7 @@ export class GlobalStateService implements OnDestroy { setTimeout(() => { // Try to login using the refresh token this.authenticationService.attemptLoginUsingRefreshToken((result: boolean) => { - if (result) { - this.loadGlobals(); - } else { + if (!result) { // Loading is finshed... this.isLoadingSubject.next(false); @@ -133,6 +131,7 @@ export class GlobalStateService implements OnDestroy { this.router.stateService.go('sign_in'); } } + // On success, loadGlobals() runs from AuthenticationService.setupUserFromResponse }); }, 100); @@ -238,29 +237,35 @@ export class GlobalStateService implements OnDestroy { }); if (this.userService.currentUser.isStaff) { + // Global GLO / feedback-chip endpoints are optional: many API builds do not mount + // GET /api/global/outcomes or /api/global/feedback_chips yet. Failing here should not + // block login or spam error toasts; task-level data still loads from unit routes. this.learningOutcomeService .query({}, {endpointFormat: LearningOutcomeService.globalEndpoint}) + .pipe( + catchError((err: unknown) => { + console.warn('Global learning outcomes (GLO) bootstrap skipped:', err); + return of([]); + }), + ) .subscribe({ next: (_response) => { subscriber.next(true); }, - error: (_response) => { - this.alerts.error('Unable to access service. Failed loading GLOs.', 6000); - }, }); this.feedbackTemplateService .query({}, {endpointFormat: FeedbackTemplateService.globalEndpoint}) + .pipe( + catchError((err: unknown) => { + console.warn('Global GLO feedback templates bootstrap skipped:', err); + return of([]); + }), + ) .subscribe({ next: (_response) => { subscriber.next(true); }, - error: (_response) => { - this.alerts.error( - 'Unable to access service. Failed loading GLO feedback templates.', - 6000, - ); - }, }); } diff --git a/src/app/sessions/states/sign-in/sign-in.component.ts b/src/app/sessions/states/sign-in/sign-in.component.ts index 8f047489d4..91c6de68cc 100644 --- a/src/app/sessions/states/sign-in/sign-in.component.ts +++ b/src/app/sessions/states/sign-in/sign-in.component.ts @@ -206,7 +206,7 @@ export class SignInComponent implements OnInit { * Perform the actions needed when the user successfully signs in. */ private actionSignInSuccess(): void { - this.globalState.loadGlobals(); + // loadGlobals() already runs inside AuthenticationService.setupUserFromResponse after sign-in this.state.go('welcome'); } @@ -246,7 +246,6 @@ export class SignInComponent implements OnInit { this.authService.signIn(signInCredentials).subscribe({ next: () => { if (this.isLtiLogin) { - this.globalState.loadGlobals(); const params = getUrlParams(document.location.href); this.state.go('lti', { ltik: params.ltik,