diff --git a/src/assets/wise5/classroomMonitor/dataExport/ComponentDataExportParams.ts b/src/assets/wise5/classroomMonitor/dataExport/ComponentDataExportParams.ts new file mode 100644 index 00000000000..f03d95da8c6 --- /dev/null +++ b/src/assets/wise5/classroomMonitor/dataExport/ComponentDataExportParams.ts @@ -0,0 +1,6 @@ +export interface ComponentDataExportParams { + canViewStudentNames: boolean; + includeOnlySubmits: boolean; + includeStudentNames: boolean; + workSelectionType: string; +} diff --git a/src/assets/wise5/classroomMonitor/dataExport/ComponentRevisionCounter.ts b/src/assets/wise5/classroomMonitor/dataExport/ComponentRevisionCounter.ts new file mode 100644 index 00000000000..1287810aba9 --- /dev/null +++ b/src/assets/wise5/classroomMonitor/dataExport/ComponentRevisionCounter.ts @@ -0,0 +1,23 @@ +export class ComponentRevisionCounter extends Map { + incrementCounter(nodeId: string, componentId: string): void { + const key = this.getKey(nodeId, componentId); + this.initializeKeyIfNecessary(key); + this.set(key, this.get(key) + 1); + } + + getCounter(nodeId: string, componentId: string): number { + const key = this.getKey(nodeId, componentId); + this.initializeKeyIfNecessary(key); + return this.get(key); + } + + private getKey(nodeId: string, componentId: string): string { + return `${nodeId}_${componentId}`; + } + + private initializeKeyIfNecessary(key: string): void { + if (!this.has(key)) { + this.set(key, 1); + } + } +} diff --git a/src/assets/wise5/classroomMonitor/dataExport/UserIdsAndStudentNames.ts b/src/assets/wise5/classroomMonitor/dataExport/UserIdsAndStudentNames.ts new file mode 100644 index 00000000000..a94087010b2 --- /dev/null +++ b/src/assets/wise5/classroomMonitor/dataExport/UserIdsAndStudentNames.ts @@ -0,0 +1,20 @@ +export class UserIdsAndStudentNames extends Map { + constructor(users: any[], canViewStudentNames: boolean) { + super(); + for (let u = 0; u < users.length; u++) { + const user = users[u]; + this.set(`userId${u + 1}`, user.id); + if (canViewStudentNames) { + this.set(`studentName${u + 1}`, user.name); + } + } + } + + getUserId(userNumber: number): number { + return this.get(`userId${userNumber}`); + } + + getStudentName(userNumber: number): string { + return this.get(`studentName${userNumber}`); + } +} diff --git a/src/assets/wise5/classroomMonitor/dataExport/data-export/data-export.component.ts b/src/assets/wise5/classroomMonitor/dataExport/data-export/data-export.component.ts index 0a8add27972..b386c7a0b95 100644 --- a/src/assets/wise5/classroomMonitor/dataExport/data-export/data-export.component.ts +++ b/src/assets/wise5/classroomMonitor/dataExport/data-export/data-export.component.ts @@ -20,6 +20,8 @@ import { UpgradeModule } from '@angular/upgrade/static'; import { MatDialog } from '@angular/material/dialog'; import { DialogWithSpinnerComponent } from '../../../directives/dialog-with-spinner/dialog-with-spinner.component'; import { DiscussionComponentDataExportStrategy } from '../strategies/DiscussionComponentDataExportStrategy'; +import { LabelComponentDataExportStrategy } from '../strategies/LabelComponentDataExportStrategy'; +import { Component as WISEComponent } from '../../../common/Component'; @Component({ selector: 'data-export', @@ -27,8 +29,14 @@ import { DiscussionComponentDataExportStrategy } from '../strategies/DiscussionC styleUrls: ['./data-export.component.scss'] }) export class DataExportComponent implements OnInit { - allowedComponentTypesForAllRevisions = ['DialogGuidance', 'Discussion', 'Match', 'OpenResponse']; - allowedComponentTypesForLatestRevisions = ['DialogGuidance', 'Match', 'OpenResponse']; + allowedComponentTypesForAllRevisions = [ + 'DialogGuidance', + 'Discussion', + 'Label', + 'Match', + 'OpenResponse' + ]; + allowedComponentTypesForLatestRevisions = ['DialogGuidance', 'Label', 'Match', 'OpenResponse']; autoScoreLabel: string = 'Auto Score'; componentExportTooltips = {}; componentExportDefaultColumnNames = [ @@ -1015,6 +1023,8 @@ export class DataExportComponent implements OnInit { this.exportOpenResponseComponent(nodeId, component); } else if (this.isEmbeddedTableComponentAndCanExport(component)) { this.exportEmbeddedComponent(nodeId, component); + } else if (component.type === 'Label') { + this.exportLabelComponent(nodeId, component); } } @@ -1033,9 +1043,23 @@ export class DataExportComponent implements OnInit { this.exportOpenResponseComponent(nodeId, component); } else if (this.isEmbeddedTableComponentAndCanExport(component)) { this.exportEmbeddedComponent(nodeId, component); + } else if (component.type === 'Label') { + this.exportLabelComponent(nodeId, component); } } + private exportLabelComponent(nodeId: string, component: any): void { + this.dataExportContext.setStrategy( + new LabelComponentDataExportStrategy(new WISEComponent(component, nodeId), { + canViewStudentNames: this.canViewStudentNames, + includeOnlySubmits: this.includeOnlySubmits, + includeStudentNames: this.includeStudentNames, + workSelectionType: this.workSelectionType + }) + ); + this.dataExportContext.export(); + } + /** * Generate an export for a specific match component. * TODO: Move these Match export functions to the MatchService. diff --git a/src/assets/wise5/classroomMonitor/dataExport/strategies/AbstractComponentDataExportStrategy.ts b/src/assets/wise5/classroomMonitor/dataExport/strategies/AbstractComponentDataExportStrategy.ts new file mode 100644 index 00000000000..053e9fc6b05 --- /dev/null +++ b/src/assets/wise5/classroomMonitor/dataExport/strategies/AbstractComponentDataExportStrategy.ts @@ -0,0 +1,265 @@ +import { Component } from '../../../common/Component'; +import { ComponentDataExportParams } from '../ComponentDataExportParams'; +import { ComponentRevisionCounter } from '../ComponentRevisionCounter'; +import { UserIdsAndStudentNames } from '../UserIdsAndStudentNames'; +import { AbstractDataExportStrategy } from './AbstractDataExportStrategy'; + +export abstract class AbstractComponentDataExportStrategy extends AbstractDataExportStrategy { + abstract COMPONENT_TYPE: string; + + canViewStudentNames: boolean; + columnNames: string[] = [ + '#', + 'Workgroup ID', + 'User ID 1', + 'Student Name 1', + 'User ID 2', + 'Student Name 2', + 'User ID 3', + 'Student Name 3', + 'Class Period', + 'Project ID', + 'Project Name', + 'Run ID', + 'Start Date', + 'End Date', + 'Server Timestamp', + 'Client Timestamp', + 'Node ID', + 'Component ID', + 'Component Part Number', + 'Step Title', + 'Component Type', + 'Component Prompt', + 'Student Data', + 'Component Revision Counter', + 'Is Submit', + 'Submit Count' + ]; + columnNameToNumber: Map = new Map(); + includeOnlySubmits: boolean; + includeStudentNames: boolean; + rowCounter: number; + workSelectionType: string; + + constructor(protected component: Component, additionalParams: ComponentDataExportParams) { + super(); + this.canViewStudentNames = additionalParams.canViewStudentNames; + this.includeOnlySubmits = additionalParams.includeOnlySubmits; + this.includeStudentNames = additionalParams.includeStudentNames; + this.workSelectionType = additionalParams.workSelectionType; + } + + abstract generateComponentWorkRow( + workgroupId: number, + userIdsAndStudentNames: UserIdsAndStudentNames, + periodName: string, + componentRevisionCounter: ComponentRevisionCounter, + componentState: any + ): string[]; + + generateComponentHeaderRow(columnNames: string[]): string[] { + return [...columnNames]; + } + + populateColumnNames(): void { + for (let c = 0; c < this.columnNames.length; c++) { + this.columnNameToNumber.set(this.columnNames[c], c); + } + } + + generateComponentWorkRows(component: Component): string[] { + let rows = []; + this.rowCounter = 1; + for (const workgroupId of this.configService.getClassmateWorkgroupIds()) { + const rowsForWorkgroup = this.generateComponentWorkRowsForWorkgroup(workgroupId, component); + rows = rows.concat(rowsForWorkgroup); + } + return rows; + } + + private generateComponentWorkRowsForWorkgroup( + workgroupId: number, + component: Component + ): string[] { + const rows = []; + const userInfo = this.configService.getUserInfoByWorkgroupId(workgroupId); + const userIdsAndStudentNames = new UserIdsAndStudentNames( + userInfo.users, + this.canViewStudentNames + ); + const componentRevisionCounter = new ComponentRevisionCounter(); + const componentStates = this.teacherDataService.getComponentStatesByWorkgroupIdAndComponentId( + workgroupId, + component.id + ); + for (let c = 0; c < componentStates.length; c++) { + const componentState = componentStates[c]; + if (this.shouldExportRow(componentState, c, componentStates.length)) { + const row = this.generateComponentWorkRow( + workgroupId, + userIdsAndStudentNames, + userInfo.periodName, + componentRevisionCounter, + componentState + ); + rows.push(row); + this.rowCounter++; + } else { + componentRevisionCounter.incrementCounter(component.nodeId, component.id); + } + } + return rows; + } + + private shouldExportRow( + componentState: any, + componentStateIndex: number, + numComponentStates: number + ): boolean { + return !( + this.includeOnlySubmitsAndIsNotSubmit(componentState) || + this.exportLatestWorkAndIsNotLatestWork(componentStateIndex, numComponentStates) + ); + } + + private includeOnlySubmitsAndIsNotSubmit(componentState: any): boolean { + return this.includeOnlySubmits && !componentState.isSubmit; + } + + private exportLatestWorkAndIsNotLatestWork( + componentStateIndex: number, + numComponentStates: number + ): boolean { + return ( + this.workSelectionType === 'exportLatestWork' && + componentStateIndex !== numComponentStates - 1 + ); + } + + /** + * Create the array that will be used as a row in the student work export + * @param rowCounter the current row number + * @param workgroupId the workgroup id + * @param userId1 the User ID 1 + * @param userId2 the User ID 2 + * @param userId3 the User ID 3 + * @param periodName the period name + * @param componentRevisionCounter the mapping of component to revision counter + * @param componentState the component state + * @return an array containing the cells in the row + */ + createStudentWorkExportRow( + workgroupId: number, + userIdsAndStudentNames: UserIdsAndStudentNames, + periodName: string, + componentRevisionCounter: ComponentRevisionCounter, + componentState: any + ): string[] { + const row = new Array(this.columnNames.length); + row.fill(''); + row[this.columnNameToNumber.get('#')] = this.rowCounter; + row[this.columnNameToNumber.get('Workgroup ID')] = workgroupId; + this.setStudentIDsAndNames(row, userIdsAndStudentNames); + row[this.columnNameToNumber.get('Class Period')] = periodName; + row[this.columnNameToNumber.get('Project ID')] = this.configService.getProjectId(); + row[this.columnNameToNumber.get('Project Name')] = this.projectService.getProjectTitle(); + row[this.columnNameToNumber.get('Run ID')] = this.configService.getRunId(); + row[this.columnNameToNumber.get('Student Work ID')] = componentState.id; + row[ + this.columnNameToNumber.get('Server Timestamp') + ] = this.utilService.convertMillisecondsToFormattedDateTime(componentState.serverSaveTime); + const clientSaveTime = new Date(componentState.clientSaveTime); + const clientSaveTimeString = + clientSaveTime.toDateString() + ' ' + clientSaveTime.toLocaleTimeString(); + row[this.columnNameToNumber.get('Client Timestamp')] = clientSaveTimeString; + row[this.columnNameToNumber.get('Node ID')] = componentState.nodeId; + row[this.columnNameToNumber.get('Component ID')] = componentState.componentId; + row[this.columnNameToNumber.get('Step Title')] = this.projectService.getNodePositionAndTitle( + componentState.nodeId + ); + const componentPartNumber = + this.projectService.getComponentPosition(componentState.nodeId, componentState.componentId) + + 1; + row[this.columnNameToNumber.get('Component Part Number')] = componentPartNumber; + row[this.columnNameToNumber.get('Component Type')] = this.component.content.type; + if (this.component.content.prompt != null) { + let prompt = this.utilService.removeHTMLTags(this.component.content.prompt); + prompt = prompt.replace(/"/g, '""'); + row[this.columnNameToNumber.get('Component Prompt')] = prompt; + } + const studentData = componentState.studentData; + row[this.columnNameToNumber.get('Student Data')] = studentData; + const isCorrect = studentData.isCorrect; + if (isCorrect != null) { + row[this.columnNameToNumber.get('Is Correct')] = isCorrect ? 1 : 0; + } + if (componentState.revisionCounter == null) { + /* + * use the revision counter obtained from the componentRevisionCounter + * mapping. this case will happen when we are exporting all student + * work. + */ + row[ + this.columnNameToNumber.get('Component Revision Counter') + ] = componentRevisionCounter.getCounter(componentState.nodeId, componentState.componentId); + } else { + /* + * use the revision counter from the value in the component state. + * this case will happen when we are exporting latest student work + * because the revision counter needs to be previously calculated + * and then set into the component state + */ + row[this.columnNameToNumber.get('Component Revision Counter')] = + componentState.revisionCounter; + } + componentRevisionCounter.incrementCounter(componentState.nodeId, componentState.componentId); + if (componentState.isSubmit) { + row[this.columnNameToNumber.get('Is Submit')] = 1; + const submitCounter = studentData.submitCounter; + if (submitCounter != null) { + row[this.columnNameToNumber.get('Submit Count')] = submitCounter; + } + } else { + row[this.columnNameToNumber.get('Is Submit')] = 0; + } + return row; + } + + private setStudentIDsAndNames(row: any[], userIdsAndStudentNames: UserIdsAndStudentNames): void { + for (let s = 1; s <= 3; s++) { + this.setUserIdIfAvailable(row, userIdsAndStudentNames, s); + this.setStudentNameIfAvailable(row, userIdsAndStudentNames, s); + } + } + + private setUserIdIfAvailable( + row: any[], + userIdsAndStudentNames: UserIdsAndStudentNames, + studentNumber: number + ): void { + const userId = userIdsAndStudentNames.getUserId(studentNumber); + if (userId != null) { + row[this.columnNameToNumber.get(`User ID ${studentNumber}`)] = userId; + } + } + + private setStudentNameIfAvailable( + row: any[], + userIdsAndStudentNames: UserIdsAndStudentNames, + studentNumber: number + ): void { + const studentName = userIdsAndStudentNames.getStudentName(studentNumber); + if (studentName != null && this.includeStudentNames) { + row[this.columnNameToNumber.get(`Student Name ${studentNumber}`)] = studentName; + } + } + + generateExportFileName(): string { + const runId = this.configService.getRunId(); + const stepNumber = this.projectService.getNodePositionById(this.component.nodeId); + const componentNumber = + this.projectService.getComponentPosition(this.component.nodeId, this.component.id) + 1; + return `${runId}_step_${stepNumber}_component_${componentNumber}_${this.COMPONENT_TYPE}_work.csv`; + } +} diff --git a/src/assets/wise5/classroomMonitor/dataExport/strategies/LabelComponentDataExportStrategy.ts b/src/assets/wise5/classroomMonitor/dataExport/strategies/LabelComponentDataExportStrategy.ts new file mode 100644 index 00000000000..2450d716e0d --- /dev/null +++ b/src/assets/wise5/classroomMonitor/dataExport/strategies/LabelComponentDataExportStrategy.ts @@ -0,0 +1,61 @@ +import { Component } from '../../../common/Component'; +import { ComponentDataExportParams } from '../ComponentDataExportParams'; +import { ComponentRevisionCounter } from '../ComponentRevisionCounter'; +import { UserIdsAndStudentNames } from '../UserIdsAndStudentNames'; +import { AbstractComponentDataExportStrategy } from './AbstractComponentDataExportStrategy'; + +export class LabelComponentDataExportStrategy extends AbstractComponentDataExportStrategy { + COMPONENT_TYPE = 'label'; + maxNumLabels: number = 0; + + constructor(component: Component, additionalParams: ComponentDataExportParams) { + super(component, additionalParams); + this.populateColumnNames(); + } + + export(): void { + this.controller.showDownloadingExportMessage(); + const components = [{ nodeId: this.component.nodeId, componentId: this.component.id }]; + this.dataExportService.retrieveStudentDataExport(components).then((result) => { + let rows = [this.generateComponentHeaderRow(this.columnNames)]; + rows = rows.concat(this.generateComponentWorkRows(this.component)); + this.addLabelHeaders(rows, this.maxNumLabels); + this.controller.generateCSVFile(rows, this.generateExportFileName()); + this.controller.hideDownloadingExportMessage(); + }); + } + + generateComponentWorkRow( + workgroupId: number, + userIdsAndStudentNames: UserIdsAndStudentNames, + periodName: string, + componentRevisionCounter: ComponentRevisionCounter, + labelComponentState: any + ): string[] { + const row = this.createStudentWorkExportRow( + workgroupId, + userIdsAndStudentNames, + periodName, + componentRevisionCounter, + labelComponentState + ); + this.updateMaxNumLabelsIfNecessary(labelComponentState); + for (const label of labelComponentState.studentData.labels) { + row.push(label.text); + } + return row; + } + + private updateMaxNumLabelsIfNecessary(labelComponentState: any): void { + const numLabels = labelComponentState.studentData.labels.length; + if (numLabels > this.maxNumLabels) { + this.maxNumLabels = numLabels; + } + } + + private addLabelHeaders(rows: string[][], maxNumLabels: number): void { + for (let i = 1; i <= maxNumLabels; i++) { + rows[0].push(`Label ${i}`); + } + } +} diff --git a/src/messages.xlf b/src/messages.xlf index 22eefc4bbea..2a09df3f350 100644 --- a/src/messages.xlf +++ b/src/messages.xlf @@ -11654,49 +11654,49 @@ Are you sure you want to proceed? Correctness column key: 0 = Incorrect, 1 = Correct, 2 = Correct bucket but wrong position src/assets/wise5/classroomMonitor/dataExport/data-export/data-export.component.ts - 125 + 133 One Workgroup Per Row src/assets/wise5/classroomMonitor/dataExport/data-export/data-export.component.ts - 164 + 172 Latest Student Work src/assets/wise5/classroomMonitor/dataExport/data-export/data-export.component.ts - 166 + 174 All Student Work src/assets/wise5/classroomMonitor/dataExport/data-export/data-export.component.ts - 168 + 176 Events src/assets/wise5/classroomMonitor/dataExport/data-export/data-export.component.ts - 170 + 178 Raw Data src/assets/wise5/classroomMonitor/dataExport/data-export/data-export.component.ts - 172 + 180 Downloading Export src/assets/wise5/classroomMonitor/dataExport/data-export/data-export.component.ts - 2091 + 2115