Skip to content

Commit 84399d6

Browse files
authored
Merge pull request #8644 from stopfstedt/6187_learner_group_associations
display sessions associated with a given learner-group.
2 parents 770dff1 + dc175a8 commit 84399d6

File tree

15 files changed

+907
-238
lines changed

15 files changed

+907
-238
lines changed
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import Component from '@glimmer/component';
2+
import { cached, tracked } from '@glimmer/tracking';
3+
import { array, fn, uniqueId } from '@ember/helper';
4+
import { on } from '@ember/modifier';
5+
import { action } from '@ember/object';
6+
import { LinkTo } from '@ember/routing';
7+
import { service } from '@ember/service';
8+
import { TrackedAsyncData } from 'ember-async-data';
9+
import t from 'ember-intl/helpers/t';
10+
import add from 'ember-math-helpers/helpers/add';
11+
import eq from 'ember-truth-helpers/helpers/eq';
12+
import or from 'ember-truth-helpers/helpers/or';
13+
import FaIcon from 'ilios-common/components/fa-icon';
14+
import SortableTh from 'ilios-common/components/sortable-th';
15+
import sortBy from 'ilios-common/helpers/sort-by';
16+
import { mapBy, uniqueValues } from 'ilios-common/utils/array-helpers';
17+
18+
export default class LearnerGroupCourseAssociationsComponent extends Component {
19+
@service iliosConfig;
20+
@service intl;
21+
@tracked sortBy = 'school';
22+
23+
crossesBoundaryConfig = new TrackedAsyncData(
24+
this.iliosConfig.itemFromConfig('academicYearCrossesCalendarYearBoundaries'),
25+
);
26+
27+
@cached
28+
get academicYearCrossesCalendarYearBoundaries() {
29+
return this.crossesBoundaryConfig.isResolved ? this.crossesBoundaryConfig.value : false;
30+
}
31+
32+
get sortedAscending() {
33+
return !this.sortBy.includes(':desc');
34+
}
35+
36+
@action
37+
setSortBy(what) {
38+
if (this.sortBy === what) {
39+
what += ':desc';
40+
}
41+
this.sortBy = what;
42+
}
43+
44+
@action
45+
sortAssociations(a, b) {
46+
const locale = this.intl.get('primaryLocale');
47+
switch (this.sortBy) {
48+
case 'school':
49+
return a.school.title.localeCompare(b.school.title, locale);
50+
case 'school:desc':
51+
return b.school.title.localeCompare(a.school.title, locale);
52+
case 'course':
53+
return a.course.title.localeCompare(b.course.title, locale);
54+
case 'course:desc':
55+
return b.course.title.localeCompare(a.course.title, locale);
56+
}
57+
return 0;
58+
}
59+
60+
@cached
61+
get associationsData() {
62+
return new TrackedAsyncData(this.getAssociations(this.args.learnerGroup));
63+
}
64+
65+
get associations() {
66+
return this.associationsData.isResolved ? this.associationsData.value : [];
67+
}
68+
69+
get hasAssociations() {
70+
return !!this.associations.length;
71+
}
72+
73+
get isLoaded() {
74+
return this.associationsData.isResolved;
75+
}
76+
77+
async getAssociations(learnerGroup) {
78+
// get sessions from the offerings and ILMs associated with the given learner group.
79+
const offerings = await learnerGroup.offerings;
80+
const ilms = await learnerGroup.ilmSessions;
81+
const arr = [...offerings, ...ilms];
82+
const sessions = await Promise.all(mapBy(arr, 'session'));
83+
const uniqueSessions = uniqueValues(sessions);
84+
85+
// add owning courses and school to their sessions
86+
const sessionsWithCourseAndSchool = await Promise.all(
87+
uniqueSessions.map(async (session) => {
88+
const course = await session.course;
89+
const school = await course.school;
90+
return { session, course, school };
91+
}),
92+
);
93+
// group these sessions by their owning courses
94+
const map = new Map();
95+
sessionsWithCourseAndSchool.forEach(({ session, course, school }) => {
96+
if (!map.has(course.id)) {
97+
map.set(course.id, {
98+
course,
99+
school,
100+
sessions: [],
101+
});
102+
}
103+
map.get(course.id).sessions.push(session);
104+
});
105+
// convert map into an array.
106+
const rhett = [...map.values()];
107+
108+
// sort sessions by title in each data object, then return the list of objects.
109+
const locale = this.intl.get('primaryLocale');
110+
return rhett.map((obj) => {
111+
obj.sessions.sort((a, b) => {
112+
return a.title.localeCompare(b.title, locale);
113+
});
114+
return obj;
115+
});
116+
}
117+
118+
<template>
119+
{{#let (uniqueId) as |templateId|}}
120+
<section
121+
class="learner-group-course-associations"
122+
data-test-learner-group-course-associations
123+
>
124+
{{#if this.isLoaded}}
125+
<div class="header" data-test-header>
126+
<h3 class="title" data-test-title>
127+
{{#if this.hasAssociations}}
128+
{{#if @isExpanded}}
129+
<button
130+
class="title link-button"
131+
type="button"
132+
aria-expanded="true"
133+
aria-controls="content-{{templateId}}"
134+
aria-label={{t "general.hideAssociatedCourses"}}
135+
data-test-toggle
136+
{{on "click" (fn @setIsExpanded false)}}
137+
>
138+
{{t "general.associatedCourses"}}
139+
({{this.associations.length}})
140+
<FaIcon @icon="caret-down" />
141+
</button>
142+
{{else}}
143+
<button
144+
class="title link-button"
145+
type="button"
146+
aria-expanded="false"
147+
aria-controls="content-{{templateId}}"
148+
aria-label={{t "general.showAssociatedCourses"}}
149+
data-test-toggle
150+
{{on "click" (fn @setIsExpanded true)}}
151+
>
152+
{{t "general.associatedCourses"}}
153+
({{this.associations.length}})
154+
<FaIcon @icon="caret-right" />
155+
</button>
156+
{{/if}}
157+
{{else}}
158+
{{t "general.associatedCourses"}}
159+
({{this.associations.length}})
160+
{{/if}}
161+
</h3>
162+
</div>
163+
{{#if this.hasAssociations}}
164+
<div
165+
id="content-{{templateId}}"
166+
class="content{{if @isExpanded '' ' hidden'}}"
167+
data-test-content
168+
hidden={{@isExpanded}}
169+
>
170+
<table data-test-associations>
171+
<thead>
172+
<tr>
173+
<SortableTh
174+
@sortedAscending={{this.sortedAscending}}
175+
@onClick={{fn this.setSortBy "school"}}
176+
@sortedBy={{or (eq this.sortBy "school") (eq this.sortBy "school:desc")}}
177+
>
178+
{{t "general.school"}}
179+
</SortableTh>
180+
<SortableTh
181+
colspan="3"
182+
@sortedAscending={{this.sortedAscending}}
183+
@onClick={{fn this.setSortBy "course"}}
184+
@sortedBy={{or (eq this.sortBy "course") (eq this.sortBy "course:desc")}}
185+
>
186+
{{t "general.course"}}
187+
</SortableTh>
188+
<th colspan="3">{{t "general.sessions"}}</th>
189+
</tr>
190+
</thead>
191+
<tbody>
192+
{{#each (sortBy this.sortAssociations this.associations) as |association|}}
193+
<tr>
194+
<td>{{association.school.title}}</td>
195+
<td colspan="3">
196+
<LinkTo @route="course" @model={{association.course}}>
197+
{{association.course.title}}
198+
{{#if this.academicYearCrossesCalendarYearBoundaries}}
199+
({{association.course.year}}
200+
-
201+
{{add association.course.year 1}})
202+
{{else}}
203+
({{association.course.year}})
204+
{{/if}}
205+
</LinkTo>
206+
</td>
207+
<td colspan="3">
208+
<ul class="sessions-list">
209+
{{#each association.sessions as |session|}}
210+
<li data-test-session>
211+
<LinkTo @route="session" @models={{array session.course session}}>
212+
{{session.title}}
213+
</LinkTo>
214+
</li>
215+
{{/each}}
216+
</ul>
217+
</td>
218+
</tr>
219+
{{/each}}
220+
</tbody>
221+
</table>
222+
</div>
223+
{{/if}}
224+
{{/if}}
225+
</section>
226+
{{/let}}
227+
</template>
228+
}

packages/frontend/app/components/learner-group/root.gjs

Lines changed: 7 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { all, map } from 'rsvp';
77
import { dropTask, enqueueTask, restartableTask, task } from 'ember-concurrency';
88
import pad from 'pad';
99
import { TrackedAsyncData } from 'ember-async-data';
10-
import { findById, mapBy, uniqueValues } from 'ilios-common/utils/array-helpers';
10+
import { uniqueValues } from 'ilios-common/utils/array-helpers';
1111
import cloneLearnerGroup from '../../utils/clone-learner-group';
1212
import countDigits from '../../utils/count-digits';
1313
import { uniqueId, fn } from '@ember/helper';
@@ -20,9 +20,7 @@ import EditableField from 'ilios-common/components/editable-field';
2020
import { on } from '@ember/modifier';
2121
import pick from 'ilios-common/helpers/pick';
2222
import set from 'ember-set-helper/helpers/set';
23-
import sortBy from 'ilios-common/helpers/sort-by';
2423
import { LinkTo } from '@ember/routing';
25-
import add from 'ember-math-helpers/helpers/add';
2624
import and from 'ember-truth-helpers/helpers/and';
2725
import InstructorManager from 'frontend/components/learner-group/instructor-manager';
2826
import InstructorsList from 'frontend/components/learner-group/instructors-list';
@@ -35,6 +33,7 @@ import BulkAssignment from 'frontend/components/learner-group/bulk-assignment';
3533
import UserManager from 'frontend/components/learner-group/user-manager';
3634
import Calendar from 'frontend/components/learner-group/calendar';
3735
import Members from 'frontend/components/learner-group/members';
36+
import CourseAssociations from 'frontend/components/learner-group/course-associations';
3837
import ExpandCollapseButton from 'ilios-common/components/expand-collapse-button';
3938
import New from 'frontend/components/learner-group/new';
4039
import FaIcon from 'ilios-common/components/fa-icon';
@@ -85,17 +84,6 @@ export default class LearnerGroupRootComponent extends Component {
8584
return this.args.learnerGroup.title;
8685
}
8786

88-
@cached
89-
get coursesData() {
90-
return new TrackedAsyncData(
91-
this.getCoursesForGroupWithSubgroupName(null, this.args.learnerGroup),
92-
);
93-
}
94-
95-
get courses() {
96-
return this.coursesData.isResolved ? this.coursesData.value : [];
97-
}
98-
9987
@cached
10088
get cohortData() {
10189
return new TrackedAsyncData(this.args.learnerGroup.cohort);
@@ -477,55 +465,6 @@ export default class LearnerGroupRootComponent extends Component {
477465
this.savedGroup = newGroups[0];
478466
});
479467

480-
async getCoursesForGroupWithSubgroupName(prefix, learnerGroup) {
481-
const offerings = await learnerGroup.offerings;
482-
const ilms = await learnerGroup.ilmSessions;
483-
const arr = [].concat(offerings, ilms);
484-
const sessions = await Promise.all(mapBy(arr, 'session'));
485-
const filteredSessions = uniqueValues(sessions.filter(Boolean));
486-
const courses = await Promise.all(mapBy(filteredSessions, 'course'));
487-
const courseObjects = courses.map((course) => {
488-
const obj = {
489-
id: course.id,
490-
courseTitle: course.title,
491-
groups: [],
492-
course,
493-
};
494-
if (prefix) {
495-
obj.groups.push(`${prefix}>${learnerGroup.title}`);
496-
}
497-
return obj;
498-
});
499-
const children = await learnerGroup.children;
500-
const childCourses = await map(children, async (child) => {
501-
return await this.getCoursesForGroupWithSubgroupName(learnerGroup.title, child);
502-
});
503-
const comb = [...courseObjects, ...childCourses.flat()];
504-
return comb.reduce((arr, obj) => {
505-
let courseObj = findById(arr, obj.id);
506-
if (!courseObj) {
507-
courseObj = {
508-
id: obj.id,
509-
courseTitle: obj.courseTitle,
510-
groups: [],
511-
course: obj.course,
512-
};
513-
arr.push(courseObj);
514-
}
515-
courseObj.groups = [...courseObj.groups, ...obj.groups];
516-
uniqueValues(courseObj.groups);
517-
return arr;
518-
}, []);
519-
}
520-
521-
crossesBoundaryConfig = new TrackedAsyncData(
522-
this.iliosConfig.itemFromConfig('academicYearCrossesCalendarYearBoundaries'),
523-
);
524-
525-
@cached
526-
get academicYearCrossesCalendarYearBoundaries() {
527-
return this.crossesBoundaryConfig.isResolved ? this.crossesBoundaryConfig.value : false;
528-
}
529468
<template>
530469
{{#if @learnerGroup.allParents}}
531470
{{#each (reverse @learnerGroup.allParents) as |parent|}}
@@ -640,28 +579,11 @@ export default class LearnerGroupRootComponent extends Component {
640579
{{/if}}
641580
</span>
642581
</div>
643-
<div class="block associatedcourses" data-test-courses>
644-
<label>
645-
{{t "general.associatedCourses"}}
646-
({{this.courses.length}}):
647-
</label>
648-
<ul>
649-
{{#each (sortBy "courseTitle" this.courses) as |obj|}}
650-
<li>
651-
<LinkTo @route="course" @model={{obj.course}}>
652-
{{obj.courseTitle}}
653-
{{#if this.academicYearCrossesCalendarYearBoundaries}}
654-
({{obj.course.year}}
655-
-
656-
{{add obj.course.year 1}})
657-
{{else}}
658-
({{obj.course.year}})
659-
{{/if}}
660-
</LinkTo>
661-
</li>
662-
{{/each}}
663-
</ul>
664-
</div>
582+
<CourseAssociations
583+
@learnerGroup={{@learnerGroup}}
584+
@isExpanded={{@showCourseAssociations}}
585+
@setIsExpanded={{@setShowCourseAssociations}}
586+
/>
665587
{{#if (and this.dataForInstructorGroupManagerLoaded this.isManagingInstructors)}}
666588
<InstructorManager
667589
@learnerGroup={{@learnerGroup}}

packages/frontend/app/controllers/learner-group.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@ import { service } from '@ember/service';
55

66
export default class LearnerGroupController extends Controller {
77
@service permissionChecker;
8-
queryParams = [{ isEditing: 'edit' }, { isBulkAssigning: 'bulkupload', sortUsersBy: 'usersBy' }];
8+
queryParams = [
9+
{ isEditing: 'edit' },
10+
{ isBulkAssigning: 'bulkupload', sortUsersBy: 'usersBy' },
11+
'showCourseAssociations',
12+
];
913

1014
@tracked isEditing = false;
1115
@tracked isBulkAssigning = false;
1216
@tracked sortUsersBy = 'fullName';
17+
@tracked showCourseAssociations = false;
1318

1419
@cached
1520
get canDeleteData() {

packages/frontend/app/styles/components.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
@forward "components/learner-group/bulk-assignment";
7373
@forward "components/learner-group/calendar";
7474
@forward "components/learner-group/cohort-user-manager";
75+
@forward "components/learner-group/course-associations";
7576
@forward "components/learner-group/header";
7677
@forward "components/learner-group/instructor-group-members-list";
7778
@forward "components/learner-group/instructor-manager";

0 commit comments

Comments
 (0)