Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 @@ -457,12 +457,17 @@ 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
isStudentMode,
this.isAnonymizeResponses()
);
this.responsesMap = this.discussionService.getResponsesMap(this.classResponses);
this.topLevelResponses = this.discussionService.getLevel1Responses(
Expand All @@ -475,7 +480,7 @@ export class DiscussionStudent extends ComponentStudent {

addClassResponse(componentState: any): void {
if (componentState.studentData.isSubmit) {
this.discussionService.setUsernames(componentState);
this.discussionService.setUsernames(componentState, 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;
}
}
20 changes: 16 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,15 @@ export class DiscussionService extends ComponentService {
getClassResponses(
componentStates: any[],
annotations = [],
isStudentMode: boolean = false
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, anonymizeResponses);
const latestInappropriateFlagAnnotation =
this.getLatestInappropriateFlagAnnotationByStudentWorkId(annotations, componentState.id);
if (isStudentMode) {
Expand All @@ -243,8 +246,17 @@ export class DiscussionService extends ComponentService {
return annotation.data.action === 'Delete';
}

setUsernames(componentState: any): void {
const workgroupId = componentState.workgroupId;
setUsernames(componentState: any, anonymizeResponses: boolean = false): void {
const { workgroupId, periodId } = componentState;
if (anonymizeResponses) {
const workgroupIds = this.configService
.getClassmateUserInfos()
.filter((userInfo) => userInfo.periodId === periodId)
.map((userInfo) => userInfo.workgroupId)
.concat(workgroupId);
componentState.usernames = new Anonymizer(workgroupId, workgroupIds).getName();
return;
}
const usernames = this.configService.getUsernamesByWorkgroupId(workgroupId);
if (usernames.length > 0) {
componentState.usernames = this.configService.getUsernamesStringByWorkgroupId(workgroupId);
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