Skip to content

Commit 01cd0cb

Browse files
feat(IdeasSummary): Show student responses (#2268)
Co-authored-by: Jonathan Lim-Breitbart <breity10@gmail.com>
1 parent 3e6e78b commit 01cd0cb

9 files changed

Lines changed: 456 additions & 34 deletions

File tree

src/app/modules/library/library-project-details/library-project-details.component.scss

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
@use '@angular/material' as mat;
2-
32
@use 'style/abstracts/functions';
4-
5-
.library-project-details {
6-
}
3+
@reference "tailwindcss";
74

85
.info-block {
96
padding: 12px;
@@ -58,6 +55,10 @@
5855
}
5956
}
6057

58+
.notice {
59+
@apply max-w-none;
60+
}
61+
6162
discourse-category-activity, unit-tags {
6263
margin-bottom: 12px;
6364
display: block;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<mat-expansion-panel
2+
[disabled]="idea.count === 0"
3+
[hideToggle]="idea.count === 0"
4+
(opened)="toggleDetails()"
5+
>
6+
<mat-expansion-panel-header>
7+
<mat-panel-title>
8+
<span class="font-normal text-sm">{{ idea.id }}. {{ idea.text }}</span>
9+
</mat-panel-title>
10+
<mat-panel-description>
11+
<span class="font-normal text-sm flex items-center">
12+
<mat-icon class="mat-18">person</mat-icon>{{ idea.count }}
13+
</span>
14+
</mat-panel-description>
15+
</mat-expansion-panel-header>
16+
<div class="text-sm bg-white p-1 rounded">
17+
Sample responses:
18+
<ul>
19+
@for (response of responses; track response.timestamp) {
20+
<li>"{{ response.text }}"</li>
21+
}
22+
</ul>
23+
</div>
24+
</mat-expansion-panel>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
@import "tailwindcss";
2+
3+
idea-summary {
4+
--mat-expansion-legacy-header-indicator-display: none;
5+
--mat-expansion-header-indicator-display: inline-block;
6+
// TODO: convert to expansion-overrides mixin when supported
7+
--mat-expansion-header-collapsed-state-height: auto;
8+
--mat-expansion-header-expanded-state-height: auto;
9+
--mat-expansion-container-elevation-shadow: none;
10+
11+
ul {
12+
margin: 0;
13+
padding: 0;
14+
list-style: none;
15+
}
16+
17+
li {
18+
@apply mt-1;
19+
}
20+
21+
.mat-expansion-panel {
22+
@apply bg-gray-100;
23+
}
24+
25+
.mat-expansion-panel-header {
26+
@apply py-1 px-2;
27+
}
28+
29+
.mat-expansion-panel-header-title {
30+
flex-grow: 15;
31+
}
32+
33+
.mat-expansion-panel-header-description {
34+
align-items: start;
35+
flex-grow: 1;
36+
}
37+
38+
.mat-content.mat-content-hide-toggle {
39+
margin-inline-end: 0;
40+
41+
.mat-expansion-panel-header-description {
42+
margin-inline-end: 0;
43+
}
44+
}
45+
46+
.mat-expansion-indicator {
47+
align-self: start;
48+
}
49+
50+
.mat-expansion-panel-body {
51+
@apply pb-2 px-2;
52+
}
53+
}
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
import { MockProviders } from 'ng-mocks';
3+
import { Observable, Subject } from 'rxjs';
4+
import { AnnotationService } from '../../../services/annotationService';
5+
import { ConfigService } from '../../../services/configService';
6+
import { CRaterService } from '../../../services/cRaterService';
7+
import { SummaryService } from '../../../components/summary/summaryService';
8+
import { TeacherDataService } from '../../../services/teacherDataService';
9+
import { TeacherProjectService } from '../../../services/teacherProjectService';
10+
import { IdeaSummaryComponent } from './idea-summary.component';
11+
import { ComponentState } from '../../../../../app/domain/componentState';
12+
import { Annotation } from '../../../common/Annotation';
13+
import { DataService } from '../../../../../app/services/data.service';
14+
import { ProjectService } from '../../../services/projectService';
15+
16+
let component: IdeaSummaryComponent;
17+
let fixture: ComponentFixture<IdeaSummaryComponent>;
18+
let annotationService: AnnotationService;
19+
let projectService: TeacherProjectService;
20+
21+
class MockProjectService {
22+
private projectSavedSource: Subject<any> = new Subject<any>();
23+
public readonly projectSaved$: Observable<any> = this.projectSavedSource.asObservable();
24+
getComponent(): any {
25+
return null;
26+
}
27+
}
28+
29+
describe('IdeaSummaryComponent', () => {
30+
beforeEach(async () => {
31+
const dataServiceSpy = jasmine.createSpyObj('DataService', ['getCurrentNode']);
32+
await TestBed.configureTestingModule({
33+
imports: [IdeaSummaryComponent],
34+
providers: [
35+
{ provide: DataService, useValue: dataServiceSpy },
36+
{ provide: ProjectService, useClass: MockProjectService },
37+
{ provide: TeacherProjectService, useClass: MockProjectService },
38+
MockProviders(
39+
AnnotationService,
40+
ConfigService,
41+
CRaterService,
42+
TeacherDataService,
43+
SummaryService
44+
)
45+
]
46+
}).compileComponents();
47+
48+
projectService = TestBed.inject(TeacherProjectService);
49+
annotationService = TestBed.inject(AnnotationService);
50+
fixture = TestBed.createComponent(IdeaSummaryComponent);
51+
component = fixture.componentInstance;
52+
53+
// Set up default inputs
54+
component.componentId = 'component1';
55+
component.nodeId = 'node1';
56+
component.idea = {
57+
id: 'idea1',
58+
text: 'Test Idea',
59+
count: 5
60+
};
61+
});
62+
63+
describe('initial state', () => {
64+
it('should initialize with empty responses array', () => {
65+
expect(component['responses']).toEqual([]);
66+
});
67+
});
68+
69+
describe('when expanding for the first time', () => {
70+
beforeEach(() => {
71+
component['responses'] = [];
72+
});
73+
74+
it('should not fetch responses again when already loaded', async () => {
75+
component['responses'] = [{ text: 'Existing response', timestamp: 123456 }];
76+
77+
const getComponentSpy = spyOn(projectService, 'getComponent');
78+
const getLatestWorkSpy = spyOn<any>(component, 'getLatestWork');
79+
80+
await component['toggleDetails']();
81+
82+
expect(getComponentSpy).not.toHaveBeenCalled();
83+
expect(getLatestWorkSpy).not.toHaveBeenCalled();
84+
});
85+
});
86+
87+
describe('getDGResponsesWithIdea()', () => {
88+
it('should return responses with the specified idea', () => {
89+
const states = [
90+
new ComponentState({
91+
workgroupId: 1,
92+
studentData: {
93+
responses: [
94+
{ text: 'Student response 1', timestamp: 111 },
95+
{ text: 'Computer response 1', ideas: [{ detected: true, name: 'idea1' }] }
96+
]
97+
}
98+
}),
99+
new ComponentState({
100+
workgroupId: 2,
101+
studentData: {
102+
responses: [
103+
{ text: 'Student response 2', timestamp: 222 },
104+
{ text: 'Computer response 2', ideas: [{ detected: true, name: 'idea2' }] }
105+
]
106+
}
107+
})
108+
];
109+
110+
const responses = component['getDGResponsesWithIdea'](states, 'idea1');
111+
expect(responses.length).toBe(1);
112+
expect(responses[0].text).toBe('Student response 1');
113+
});
114+
115+
it('should return only one response per workgroup', () => {
116+
const states = [
117+
new ComponentState({
118+
workgroupId: 1,
119+
studentData: {
120+
responses: [
121+
{ text: 'Student response 1a', timestamp: 111 },
122+
{ text: 'Computer response 1a', ideas: [{ detected: true, name: 'idea1' }] }
123+
]
124+
}
125+
}),
126+
new ComponentState({
127+
workgroupId: 1,
128+
studentData: {
129+
responses: [
130+
{ text: 'Student response 1b', timestamp: 222 },
131+
{ text: 'Computer response 1b', ideas: [{ detected: true, name: 'idea1' }] }
132+
]
133+
}
134+
})
135+
];
136+
137+
const responses = component['getDGResponsesWithIdea'](states, 'idea1');
138+
expect(responses.length).toBe(1);
139+
});
140+
141+
it('should return empty array when no ideas match', () => {
142+
const states = [
143+
new ComponentState({
144+
workgroupId: 1,
145+
studentData: {
146+
responses: [
147+
{ text: 'Student response', timestamp: 111 },
148+
{ text: 'Computer response', ideas: [{ detected: true, name: 'idea2' }] }
149+
]
150+
}
151+
})
152+
];
153+
154+
const responses = component['getDGResponsesWithIdea'](states, 'idea1');
155+
expect(responses.length).toBe(0);
156+
});
157+
158+
it('should skip responses where idea is not detected', () => {
159+
const states = [
160+
new ComponentState({
161+
workgroupId: 1,
162+
studentData: {
163+
responses: [
164+
{ text: 'Student response', timestamp: 111 },
165+
{ text: 'Computer response', ideas: [{ detected: false, name: 'idea1' }] }
166+
]
167+
}
168+
})
169+
];
170+
171+
const responses = component['getDGResponsesWithIdea'](states, 'idea1');
172+
expect(responses.length).toBe(0);
173+
});
174+
});
175+
176+
describe('getORResponsesWithIdea()', () => {
177+
it('should return responses with matching annotations', () => {
178+
const states = [
179+
new ComponentState({
180+
id: 1,
181+
workgroupId: 1,
182+
clientSaveTime: 123456,
183+
studentData: { response: 'Student answer 1' }
184+
}),
185+
new ComponentState({
186+
id: 2,
187+
workgroupId: 2,
188+
clientSaveTime: 234567,
189+
studentData: { response: 'Student answer 2' }
190+
})
191+
];
192+
193+
const annotations = [
194+
new Annotation({
195+
studentWorkId: 1,
196+
data: { ideas: [{ detected: true, name: 'idea1' }] }
197+
})
198+
];
199+
200+
spyOn(annotationService, 'getAnnotationsByNodeIdComponentId').and.returnValue(annotations);
201+
const responses = component['getORResponsesWithIdea'](states, 'idea1');
202+
expect(responses.length).toBe(1);
203+
expect(responses[0].text).toBe('Student answer 1');
204+
expect(responses[0].timestamp).toBe(123456);
205+
});
206+
207+
it('should return empty array when no annotations match', () => {
208+
const states = [
209+
new ComponentState({
210+
id: 1,
211+
workgroupId: 1,
212+
clientSaveTime: 123456,
213+
studentData: { response: 'Student answer' }
214+
})
215+
];
216+
217+
const annotations = [
218+
new Annotation({
219+
studentWorkId: 2,
220+
data: { ideas: [{ detected: true, name: 'idea1' }] }
221+
})
222+
];
223+
224+
spyOn(annotationService, 'getAnnotationsByNodeIdComponentId').and.returnValue(annotations);
225+
const responses = component['getORResponsesWithIdea'](states, 'idea1');
226+
expect(responses.length).toBe(0);
227+
});
228+
229+
it('should filter annotations by idea name and detected status', () => {
230+
const states = [
231+
new ComponentState({
232+
id: 1,
233+
workgroupId: 1,
234+
clientSaveTime: 123456,
235+
studentData: { response: 'Student answer 1' }
236+
}),
237+
new ComponentState({
238+
id: 2,
239+
workgroupId: 2,
240+
clientSaveTime: 234567,
241+
studentData: { response: 'Student answer 2' }
242+
})
243+
];
244+
245+
const annotations = [
246+
new Annotation({
247+
studentWorkId: 1,
248+
data: { ideas: [{ detected: true, name: 'idea1' }] }
249+
}),
250+
new Annotation({
251+
studentWorkId: 2,
252+
data: { ideas: [{ detected: false, name: 'idea1' }] }
253+
})
254+
];
255+
256+
spyOn(annotationService, 'getAnnotationsByNodeIdComponentId').and.returnValue(annotations);
257+
const responses = component['getORResponsesWithIdea'](states, 'idea1');
258+
expect(responses.length).toBe(1);
259+
expect(responses[0].text).toBe('Student answer 1');
260+
});
261+
});
262+
});

0 commit comments

Comments
 (0)