Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/assets/wise5/common/ComponentContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { DynamicPrompt } from '../directives/dynamic-prompt/DynamicPrompt';

export interface ComponentContent {
id: string;
anonymizeResponses?: boolean;
connectedComponents?: any[];
constraints?: any[];
cRaterRubric?: CRaterRubric;
Expand Down
55 changes: 55 additions & 0 deletions src/assets/wise5/components/discussion/Anonymizer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Anonymizer } from './Anonymizer';

describe('Anonymizer', () => {
it('should assign a valid anonymous name based on the index of the provided id', () => {
const ids = [100, 200, 300];

const anonymizer100 = new Anonymizer(100, ids);
// Since 100 is at index 0, it should be mapped to the first animal (Tiger)
expect(anonymizer100.getName()).toEqual('Anonymous Tiger');

const anonymizer200 = new Anonymizer(200, ids);
// 200 is at index 1 -> Lion
expect(anonymizer200.getName()).toEqual('Anonymous Lion');

const anonymizer300 = new Anonymizer(300, ids);
// 300 is at index 2 -> Fox
expect(anonymizer300.getName()).toEqual('Anonymous Fox');
});

it('should append numeric suffixes when the number of ids exceeds the available name options (86)', () => {
// There are 86 predefined internal names, so we create 87 ids to trigger the suffix logic.
const manyIds = Array.from({ length: 87 }, (_, i) => i);

// The very first user (index 0) will now be "Tiger 1" instead of "Tiger"
const firstAnonymizer = new Anonymizer(0, manyIds);
expect(firstAnonymizer.getName()).toEqual('Anonymous Tiger 1');

// The 86th user (index 85, the last of the first batch) will be "Yeti 1"
const endOfFirstBatch = new Anonymizer(85, manyIds);
expect(endOfFirstBatch.getName()).toEqual('Anonymous Yeti 1');

// The 87th user (index 86) overflows into the second batch and becomes "Tiger 2"
const overflowAnonymizer = new Anonymizer(86, manyIds);
expect(overflowAnonymizer.getName()).toEqual('Anonymous Tiger 2');
});

it('should append higher numeric suffixes dynamically if ids continue to increase vastly', () => {
// Let's create an array of 200 ids (which will overflow into the 3rd batch since 86 * 2 = 172)
const massiveIds = Array.from({ length: 200 }, (_, i) => i);

// Index 171 is the end of the second batch (Yeti 2)
const endOfSecondBatch = new Anonymizer(171, massiveIds);
expect(endOfSecondBatch.getName()).toEqual('Anonymous Yeti 2');

// Index 172 starts the third batch (Tiger 3)
const startOfThirdBatch = new Anonymizer(172, massiveIds);
expect(startOfThirdBatch.getName()).toEqual('Anonymous Tiger 3');
});

it('should support a custom prefix name', () => {
const ids = [10, 20];
const anonymizer = new Anonymizer(10, ids);
expect(anonymizer.getName('Participant')).toEqual('Participant Tiger');
});
});
113 changes: 113 additions & 0 deletions src/assets/wise5/components/discussion/Anonymizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* Generates an anonymous student name using a list of predefined names.
* Maps a specific ID against a list of IDs to consistently assign a name based on its index.
* If the number of IDs exceeds the available names, numeric suffixes are appended (e.g., "Student Tiger 1", "Student Tiger 2").
*/
export class Anonymizer {
private nameOptions = [
$localize`Tiger`,
$localize`Lion`,
$localize`Fox`,
$localize`Owl`,
$localize`Panda`,
$localize`Hawk`,
$localize`Mole`,
$localize`Falcon`,
$localize`Orca`,
$localize`Eagle`,
$localize`Manta`,
$localize`Otter`,
$localize`Cat`,
$localize`Zebra`,
$localize`Flea`,
$localize`Wolf`,
$localize`Dragon`,
$localize`Seal`,
$localize`Cobra`,
$localize`Bug`,
$localize`Gecko`,
$localize`Fish`,
$localize`Koala`,
$localize`Mouse`,
$localize`Wombat`,
$localize`Shark`,
$localize`Whale`,
$localize`Sloth`,
$localize`Slug`,
$localize`Ant`,
$localize`Mantis`,
$localize`Bat`,
$localize`Rhino`,
$localize`Gator`,
$localize`Monkey`,
$localize`Swan`,
$localize`Ray`,
$localize`Crow`,
$localize`Goat`,
$localize`Marmot`,
$localize`Dog`,
$localize`Finch`,
$localize`Puffin`,
$localize`Fly`,
$localize`Camel`,
$localize`Kiwi`,
$localize`Spider`,
$localize`Lizard`,
$localize`Robin`,
$localize`Bear`,
$localize`Boa`,
$localize`Cow`,
$localize`Crab`,
$localize`Mule`,
$localize`Moth`,
$localize`Lynx`,
$localize`Moose`,
$localize`Skunk`,
$localize`Mako`,
$localize`Liger`,
$localize`Llama`,
$localize`Shrimp`,
$localize`Parrot`,
$localize`Pig`,
$localize`Clam`,
$localize`Urchin`,
$localize`Toucan`,
$localize`Frog`,
$localize`Toad`,
$localize`Turtle`,
$localize`Viper`,
$localize`Trout`,
$localize`Hare`,
$localize`Bee`,
$localize`Krill`,
$localize`Dodo`,
$localize`Tuna`,
$localize`Loon`,
$localize`Leech`,
$localize`Python`,
$localize`Wasp`,
$localize`Yak`,
$localize`Snake`,
$localize`Duck`,
$localize`Worm`,
$localize`Yeti`
];

constructor(
private id: number,
private ids: number[]
) {}

getName(prefix: string = $localize`Anonymous`): string {
let names = this.nameOptions;
if (this.ids.length > this.nameOptions.length) {
names = this.nameOptions.map((name) => name + ' ' + 1);
let i = 2;
while (this.ids.length > names.length) {
names = names.concat(this.nameOptions.map((name) => name + ' ' + i));
i++;
}
}
return `${prefix} ${names.at(this.ids.indexOf(this.id))}`;
}
}
1 change: 1 addition & 0 deletions src/assets/wise5/components/discussion/DiscussionInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export class DiscussionInfo extends ComponentInfo {
showSubmitButton: false,
isStudentAttachmentEnabled: true,
gateClassmateResponses: true,
anonymizeResponses: false,
constraints: []
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,11 @@
>
Students must create a post before viewing classmates' posts
</mat-checkbox>
<mat-checkbox
color="primary"
[(ngModel)]="componentContent.anonymizeResponses"
(ngModelChange)="componentChanged()"
i18n
>
Anonymize (students cannot see classmates' names)
</mat-checkbox>
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export class DiscussionShowWorkComponent extends ComponentShowWorkDirective {
const classResponses = this.discussionService.getClassResponses(
componentStates,
annotations,
this.workgroupId,
isStudentMode
);
const isGradingMode = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -457,12 +457,18 @@ export class DiscussionStudent extends ComponentStudent {
return this.componentContent.gateClassmateResponses;
}

protected isAnonymizeResponses(): boolean {
return this.componentContent.anonymizeResponses;
}

setClassResponses(componentStates: any[], annotations: any[] = []): void {
const isStudentMode = true;
this.classResponses = this.discussionService.getClassResponses(
componentStates,
annotations,
isStudentMode
this.workgroupId,
isStudentMode,
this.isAnonymizeResponses()
);
this.responsesMap = this.discussionService.getResponsesMap(this.classResponses);
this.topLevelResponses = this.discussionService.getLevel1Responses(
Expand All @@ -475,7 +481,11 @@ export class DiscussionStudent extends ComponentStudent {

addClassResponse(componentState: any): void {
if (componentState.studentData.isSubmit) {
this.discussionService.setUsernames(componentState);
this.discussionService.setUsernames(
componentState,
this.workgroupId,
this.isAnonymizeResponses()
);
componentState.replies = [];
this.classResponses.push(componentState);
this.addResponseToResponsesMap(this.responsesMap, componentState);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { DiscussionStudent } from '../discussion-student/discussion-student.comp
})
export class DiscussionTeacherComponent extends DiscussionStudent {
@Input() periodId: number;
@Input() anonymizeResponses: boolean;

ngOnChanges(changes: SimpleChanges): void {
if (changes.component) {
Expand All @@ -55,4 +56,8 @@ export class DiscussionTeacherComponent extends DiscussionStudent {
disableComponentIfNecessary(): void {
// no need to disable the component for teacher
}

protected isAnonymizeResponses(): boolean {
return this.anonymizeResponses;
}
}
30 changes: 26 additions & 4 deletions src/assets/wise5/components/discussion/discussionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { HttpClient } from '@angular/common/http';
import { forkJoin, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { serverSaveTimeComparator } from '../../common/object/object';
import { Anonymizer } from './Anonymizer';

@Injectable()
export class DiscussionService extends ComponentService {
Expand All @@ -25,6 +26,7 @@ export class DiscussionService extends ComponentService {
component.prompt = '';
component.isStudentAttachmentEnabled = true;
component.gateClassmateResponses = true;
component.anonymizeResponses = false;
return component;
}

Expand Down Expand Up @@ -211,14 +213,16 @@ export class DiscussionService extends ComponentService {
getClassResponses(
componentStates: any[],
annotations = [],
isStudentMode: boolean = false
myWorkgroupId: number,
isStudentMode: boolean = false,
anonymizeResponses: boolean = false
): any[] {
const classResponses = [];
componentStates = componentStates.sort(serverSaveTimeComparator);
for (const componentState of componentStates) {
if (componentState.studentData.isSubmit) {
componentState.replies = [];
this.setUsernames(componentState);
this.setUsernames(componentState, myWorkgroupId, anonymizeResponses);
const latestInappropriateFlagAnnotation =
this.getLatestInappropriateFlagAnnotationByStudentWorkId(annotations, componentState.id);
if (isStudentMode) {
Expand All @@ -243,8 +247,16 @@ export class DiscussionService extends ComponentService {
return annotation.data.action === 'Delete';
}

setUsernames(componentState: any): void {
const workgroupId = componentState.workgroupId;
setUsernames(
componentState: any,
myWorkgroupId: number,
anonymizeResponses: boolean = false
): void {
const { workgroupId } = componentState;
if (anonymizeResponses && workgroupId !== myWorkgroupId) {
this.setAnonymousUsername(componentState);
return;
}
const usernames = this.configService.getUsernamesByWorkgroupId(workgroupId);
if (usernames.length > 0) {
componentState.usernames = this.configService.getUsernamesStringByWorkgroupId(workgroupId);
Expand All @@ -259,6 +271,16 @@ export class DiscussionService extends ComponentService {
}
}

private setAnonymousUsername(componentState: any): void {
const { workgroupId, periodId } = componentState;
const workgroupIds = this.configService
.getClassmateUserInfos()
.filter((userInfo) => userInfo.periodId === periodId)
.map((userInfo) => userInfo.workgroupId)
.concat(workgroupId);
componentState.usernames = new Anonymizer(workgroupId, workgroupIds).getName();
}

getResponsesMap(componentStates: any[]): any {
const responsesMap: any = {};
for (const componentState of componentStates) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,35 +1,58 @@
import { CommonModule } from '@angular/common';
import { Component, Input, OnInit } from '@angular/core';
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { Component as WISEComponent } from '../../../common/Component';
import { TeacherSummaryDisplayComponent } from '../teacher-summary-display.component';
import { ComponentFactory } from '../../../common/ComponentFactory';
import { DiscussionTeacherComponent } from '../../../components/discussion/discussion-teacher/discussion-teacher.component';
import { MatSlideToggle } from '@angular/material/slide-toggle';
import { FormsModule } from '@angular/forms';

@Component({
imports: [CommonModule, DiscussionTeacherComponent],
imports: [CommonModule, DiscussionTeacherComponent, FormsModule, MatSlideToggle],
selector: 'discussion-summary',
styleUrl: '../../summary-display/summary-display.component.scss',
template: `
<div [class.expanded]="expanded">
<h2 class="mat-subtitle-1" i18n>Class Discussion</h2>
<div class="mb-4 flex flex-wrap gap-4 justify-between items-center">
<mat-slide-toggle
[(ngModel)]="anonymizeResponses"
(change)="anonymizeResponsesChanged()"
i18n
>
Hide student names
</mat-slide-toggle>
@if (component.content.anonymizeResponses) {
<span class="mat-caption" i18n
>Note: Students do not see each other's names in this activity.</span
>
}
</div>
<discussion-teacher
class="max-h-160 block overflow-y-auto"
[class.max-h-none]="expanded"
[nodeId]="nodeId"
[component]="component"
[periodId]="periodId"
[anonymizeResponses]="anonymizeResponses"
[mode]="'summary'"
/>
</div>
`
})
export class DiscussionSummaryComponent extends TeacherSummaryDisplayComponent implements OnInit {
@Input() anonymizeResponses: boolean;
protected component: WISEComponent;
@Input() expanded: boolean;
@Output() anonymizeResponsesChange = new EventEmitter<boolean>();

ngOnInit(): void {
let content = this.projectService.getComponent(this.nodeId, this.componentId);
content = this.projectService.injectAssetPaths(content);
this.component = new ComponentFactory().getComponent(content, this.nodeId);
}

protected anonymizeResponsesChanged() {
this.anonymizeResponsesChange.emit(this.anonymizeResponses);
}
}
Loading
Loading