diff --git a/packages/frontend/app/components/reports/choose-course.hbs b/packages/frontend/app/components/reports/choose-course.hbs new file mode 100644 index 0000000000..d71adc56a0 --- /dev/null +++ b/packages/frontend/app/components/reports/choose-course.hbs @@ -0,0 +1,52 @@ +
+
+ + {{#if (gt this.filteredSchools.length 1)}} + + {{else}} + {{this.selectedSchool.title}} + {{/if}} +
+ {{#each this.selectedSchool.years as |y|}} + + {{/each}} +
\ No newline at end of file diff --git a/packages/frontend/app/components/reports/choose-course.js b/packages/frontend/app/components/reports/choose-course.js new file mode 100644 index 0000000000..30e43ac44e --- /dev/null +++ b/packages/frontend/app/components/reports/choose-course.js @@ -0,0 +1,66 @@ +import Component from '@glimmer/component'; +import { service } from '@ember/service'; +import { cached, tracked } from '@glimmer/tracking'; +import { TrackedAsyncData } from 'ember-async-data'; +import { DateTime } from 'luxon'; + +export default class ReportsChooseCourse extends Component { + @service iliosConfig; + @service currentUser; + + @tracked selectedSchoolId = null; + @tracked expandedYear; + + constructor() { + super(...arguments); + let { month, year } = DateTime.now(); + if (month < 7) { + // before July 1st (start of a new academic year) show the year before + year--; + } + this.expandedYear = year; + } + + userModel = new TrackedAsyncData(this.currentUser.getModel()); + + @cached + get user() { + return this.userModel.isResolved ? this.userModel.value : null; + } + + get primarySchool() { + return this.args.schools.find(({ id }) => id === this.user?.belongsTo('school').id()); + } + + crossesBoundaryConfig = new TrackedAsyncData( + this.iliosConfig.itemFromConfig('academicYearCrossesCalendarYearBoundaries'), + ); + + @cached + get academicYearCrossesCalendarYearBoundaries() { + return this.crossesBoundaryConfig.isResolved ? this.crossesBoundaryConfig.value : false; + } + + get bestSelectedSchoolId() { + if (this.selectedSchoolId) { + return this.selectedSchoolId; + } + return this.primarySchool?.id; + } + + get selectedSchool() { + return this.args.schools.find(({ id }) => id === this.bestSelectedSchoolId); + } + + get filteredSchools() { + return this.args.schools.filter(({ years }) => years.length); + } + + toggleYear = (year) => { + if (this.expandedYear === year) { + this.expandedYear = null; + } else { + this.expandedYear = year; + } + }; +} diff --git a/packages/frontend/app/components/reports/curriculum.hbs b/packages/frontend/app/components/reports/curriculum.hbs new file mode 100644 index 0000000000..84d343db02 --- /dev/null +++ b/packages/frontend/app/components/reports/curriculum.hbs @@ -0,0 +1,47 @@ +
+
+ {{#if @selectedCourseIds.length}} + {{t "general.run"}} + {{#if this.reportIsRunning}} + {{this.selectedReport.label}} + {{else}} + + {{/if}} + {{t "general.reportForCourses" courseCount=@selectedCourseIds.length}} + {{else}} + {{t "general.selectCoursesToRunReport"}} + {{/if}} +
+ {{#if this.reportIsRunning}} + + {{else}} +
+ +
+ + {{/if}} +
\ No newline at end of file diff --git a/packages/frontend/app/components/reports/curriculum.js b/packages/frontend/app/components/reports/curriculum.js new file mode 100644 index 0000000000..c616d14f35 --- /dev/null +++ b/packages/frontend/app/components/reports/curriculum.js @@ -0,0 +1,74 @@ +import Component from '@glimmer/component'; +import { service } from '@ember/service'; +import { cached, tracked } from '@glimmer/tracking'; +import { ensureSafeComponent } from '@embroider/util'; +import SessionObjectives from './curriculum/session-objectives'; +import LearnerGroups from './curriculum/learner-groups'; + +export default class ReportsCurriculumComponent extends Component { + @service store; + @service graphql; + @service router; + @service intl; + + @tracked searchResults = null; + @tracked reportResults = null; + @tracked reportIsRunning = false; + + reportList = [ + { value: 'sessionObjectives', label: this.intl.t('general.sessionObjectives') }, + { value: 'learnerGroups', label: this.intl.t('general.learnerGroups') }, + ]; + + get passedCourseIds() { + return this.args.selectedCourseIds?.map(Number) ?? []; + } + + get selectedReport() { + return this.reportList.find((r) => r.value === this.args.report) ?? this.reportList[0]; + } + + @cached + get allCourses() { + return this.args.schools.reduce((all, school) => { + const courses = school.years.reduce((arr, year) => { + return [...arr, ...year.courses]; + }, []); + return [...all, ...courses]; + }, []); + } + + get selectedCourses() { + return this.allCourses.filter((course) => this.passedCourseIds.includes(Number(course.id))); + } + + get showCourseYears() { + const years = this.selectedCourses.map(({ year }) => year); + return years.some((year) => year !== years[0]); + } + + get reportResultsComponent() { + switch (this.selectedReport.value) { + case 'sessionObjectives': + return ensureSafeComponent(SessionObjectives, this); + case 'learnerGroups': + return ensureSafeComponent(LearnerGroups, this); + } + + return false; + } + + pickCourse = (id) => { + this.args.setSelectedCourseIds([...this.passedCourseIds, id].sort()); + }; + + removeCourse = (id) => { + this.reportIsRunning = false; + this.args.setSelectedCourseIds(this.passedCourseIds.filter((i) => i !== Number(id)).sort()); + }; + + changeSelectedReport = ({ target }) => { + this.reportIsRunning = false; + this.args.setReport(target.value); + }; +} diff --git a/packages/frontend/app/components/reports/curriculum/learner-groups.hbs b/packages/frontend/app/components/reports/curriculum/learner-groups.hbs new file mode 100644 index 0000000000..ec6bfa7a97 --- /dev/null +++ b/packages/frontend/app/components/reports/curriculum/learner-groups.hbs @@ -0,0 +1,35 @@ + +{{#unless this.reportRunning}} +
+ + + + + + + + + + + + {{#each (sort-by "courseTitle" this.summary) as |o|}} + + + + + + + {{/each}} + +
{{t "general.resultsSummary"}}
{{t "general.course"}}{{t "general.sessions"}}{{t "general.instructors"}}{{t "general.learnerGroups"}}
+ + {{o.courseTitle}} + + {{o.sessionCount}}{{o.instructorsCount}}{{o.learnerGroupsCount}}
+
+{{/unless}} \ No newline at end of file diff --git a/packages/frontend/app/components/reports/curriculum/learner-groups.js b/packages/frontend/app/components/reports/curriculum/learner-groups.js new file mode 100644 index 0000000000..8e4a083b47 --- /dev/null +++ b/packages/frontend/app/components/reports/curriculum/learner-groups.js @@ -0,0 +1,173 @@ +import Component from '@glimmer/component'; +import { service } from '@ember/service'; +import PapaParse from 'papaparse'; +import { dropTask, timeout } from 'ember-concurrency'; +import createDownloadFile from 'frontend/utils/create-download-file'; +import { DateTime } from 'luxon'; +import { cached } from '@glimmer/tracking'; +import { TrackedAsyncData } from 'ember-async-data'; +import { uniqueById } from 'ilios-common/utils/array-helpers'; + +export default class ReportsCurriculumLearnerGroupsComponent extends Component { + @service router; + @service intl; + @service graphql; + + @cached + get reportResultsData() { + const courseIds = this.args.courses.map((c) => c.id); + const filters = [`ids: [${courseIds.join(', ')}]`]; + const userData = ['id', 'firstName', 'lastName', 'middleName', 'displayName'].join(', '); + const sessionData = [ + 'id', + 'title', + `offerings { id, startDate, endDate, instructors { ${userData} }, instructorGroups { id, users { ${userData} } }, learnerGroups { id, title } }`, + `ilmSession { id, dueDate, hours, instructors { ${userData} }, instructorGroups { id, users { ${userData} } }, learnerGroups { id, title } }`, + ].join(', '); + + const data = ['id', 'title', 'year', `sessions { ${sessionData} }`]; + return new TrackedAsyncData(this.graphql.find('courses', filters, data.join(', '))); + } + + get reportResults() { + if (!this.reportResultsData.isResolved) { + return []; + } + return this.reportResultsData.value.data.courses; + } + + get reportRunning() { + return this.reportResultsData.isPending; + } + + get reportWithInstructors() { + return this.reportResults.map((c) => { + c.sessions = c.sessions.map((s) => { + const offeringInstructors = s.offerings.map((o) => o.instructors.map((i) => i)).flat(); + const ilmInstructors = s.ilmSession?.instructors.map((i) => i) ?? []; + const offeringInstructorGroups = s.offerings.map((o) => o.instructorGroups).flat(); + const ilmInstructorGroups = s.ilmSession?.instructorGroups ?? []; + const instructorGroupInstructors = [...offeringInstructorGroups, ...ilmInstructorGroups] + .map((ig) => ig.users) + .flat(); + const instructors = [ + ...offeringInstructors, + ...ilmInstructors, + ...instructorGroupInstructors, + ].map((i) => this.getUserName(i)); + + s.instructors = [...new Set(instructors)].sort(); + return s; + }); + + return c; + }); + } + + get reportWithLearnerGroups() { + return this.reportWithInstructors.map((c) => { + c.sessions = c.sessions.map((s) => { + const offeringLearnerGroups = s.offerings.map((o) => o.learnerGroups.map((g) => g)).flat(); + const ilmLearnerGroups = s.ilmSession?.learnerGroups.map((g) => g) ?? []; + const learnerGroups = [...offeringLearnerGroups, ...ilmLearnerGroups]; + + s.learnerGroups = uniqueById(learnerGroups) + .map(({ title }) => title) + .sort(); + return s; + }); + + return c; + }); + } + + get summary() { + return this.reportWithLearnerGroups.map((c) => { + return { + courseId: c.id, + courseTitle: c.title, + sessionCount: c.sessions.length, + learnerGroupsCount: c.sessions.reduce((acc, s) => acc + s.learnerGroups.length, 0), + instructorsCount: c.sessions.reduce((acc, s) => acc + s.instructors.length, 0), + }; + }); + } + + get results() { + const origin = window.location.origin; + return this.reportWithLearnerGroups.reduce((acc, c) => { + c.sessions.forEach((s) => { + const path = this.router.urlFor('session', c.id, s.id); + let firstOfferingDate; + const firstOffering = s.offerings.sort( + (a, b) => DateTime.fromISO(a.startDate) - DateTime.fromISO(b.startDate), + )[0]; + if (firstOffering) { + firstOfferingDate = firstOffering.startDate; + } else if (s.ilmSession) { + firstOfferingDate = s.ilmSession.dueDate; + } + s.learnerGroups.forEach((title) => { + acc.push({ + courseId: c.id, + courseTitle: c.title, + year: c.year, + sessionTitle: s.title, + firstOfferingDate: firstOfferingDate + ? DateTime.fromISO(firstOfferingDate).toLocaleString(this.intl.primarlyLocale) + : '', + instructors: s.instructors, + title, + link: `${origin}${path}`, + }); + }); + }); + return acc; + }, []); + } + + get sortedResults() { + return this.results.sort(this.sortResults); + } + + getUserName = (user) => { + if (user.displayName) { + return user.displayName; + } + const middleInitial = user.middleName ? user.middleName.charAt(0) : false; + if (middleInitial) { + return `${user.firstName} ${middleInitial}. ${user.lastName}`; + } else { + return `${user.firstName} ${user.lastName}`; + } + }; + + sortResults = (a, b) => { + if (a.courseTitle !== b.courseTitle) { + return a.courseTitle.localeCompare(b.courseTitle); + } + + return a.sessionTitle.localeCompare(b.sessionTitle); + }; + + downloadReport = dropTask(async () => { + const data = this.sortedResults.map((o) => { + const rhett = {}; + rhett[this.intl.t('general.id')] = o.courseId; + rhett[this.intl.t('general.course')] = o.courseTitle; + rhett[this.intl.t('general.year')] = o.year; + rhett[this.intl.t('general.session')] = o.sessionTitle; + rhett[this.intl.t('general.firstOffering')] = o.firstOfferingDate; + rhett[this.intl.t('general.instructors')] = o.instructors.join(', '); + rhett[this.intl.t('general.learnerGroupTitle')] = o.title; + rhett[this.intl.t('general.link')] = o.link; + + return rhett; + }); + const csv = PapaParse.unparse(data); + this.finishedBuildingReport = true; + createDownloadFile(`learner-groups.csv`, csv, 'text/csv'); + await timeout(2000); + this.finishedBuildingReport = false; + }); +} diff --git a/packages/frontend/app/components/reports/curriculum/loading.hbs b/packages/frontend/app/components/reports/curriculum/loading.hbs new file mode 100644 index 0000000000..ebbce8ee86 --- /dev/null +++ b/packages/frontend/app/components/reports/curriculum/loading.hbs @@ -0,0 +1,57 @@ +
+
+ {{#if this.guessCourses}} + + {{t "general.reportForCourses" courseCount=this.guessCourses}} + {{else}} + {{t "general.selectCoursesToRunReport"}} + {{/if}} +
+
+ +
+
+
+ + +
+
    +
  • + +
  • +
  • + +
      + {{! template-lint-disable no-unused-block-params }} + {{#each (repeat 5) as |empty|}} +
    • + +
    • + {{/each}} +
    +
  • +
+
+
\ No newline at end of file diff --git a/packages/frontend/app/components/reports/curriculum/loading.js b/packages/frontend/app/components/reports/curriculum/loading.js new file mode 100644 index 0000000000..c0608def3e --- /dev/null +++ b/packages/frontend/app/components/reports/curriculum/loading.js @@ -0,0 +1,48 @@ +import Component from '@glimmer/component'; +import { service } from '@ember/service'; +import { cached } from '@glimmer/tracking'; +import { TrackedAsyncData } from 'ember-async-data'; + +export default class ReportsCurriculumLoading extends Component { + @service router; + @service intl; + @service store; + @service currentUser; + + userModel = new TrackedAsyncData(this.currentUser.getModel()); + + @cached + get user() { + return this.userModel.isResolved ? this.userModel.value : null; + } + + @cached + get allSchools() { + return this.store.peekAll('school'); + } + + get primarySchool() { + return this.allSchools.find(({ id }) => id === this.user?.belongsTo('school').id()); + } + + reportList = [ + { value: 'sessionObjectives', label: this.intl.t('general.sessionObjectives') }, + { value: 'learnerGroups', label: this.intl.t('general.learnerGroups') }, + ]; + + get queryParams() { + return this.router.currentRoute.queryParams; + } + + get guessCourses() { + return this.queryParams?.courses?.split('-').length; + } + + get reportLabel() { + if (this.queryParams?.report) { + return this.reportList.find(({ value }) => value === this.queryParams.report).label; + } + + return this.reportList[0].label; + } +} diff --git a/packages/frontend/app/components/reports/curriculum/result-buttons.hbs b/packages/frontend/app/components/reports/curriculum/result-buttons.hbs new file mode 100644 index 0000000000..7ed78a0a4c --- /dev/null +++ b/packages/frontend/app/components/reports/curriculum/result-buttons.hbs @@ -0,0 +1,19 @@ +
+ {{#if @running}} + + {{else}} + + + {{/if}} +
\ No newline at end of file diff --git a/packages/frontend/app/components/reports/curriculum/session-objectives.hbs b/packages/frontend/app/components/reports/curriculum/session-objectives.hbs new file mode 100644 index 0000000000..ef15b1155c --- /dev/null +++ b/packages/frontend/app/components/reports/curriculum/session-objectives.hbs @@ -0,0 +1,35 @@ + +{{#unless this.reportRunning}} +
+ + + + + + + + + + + + {{#each (sort-by "courseTitle" this.summary) as |o|}} + + + + + + + {{/each}} + +
{{t "general.resultsSummary"}}
{{t "general.course"}}{{t "general.sessions"}}{{t "general.instructors"}}{{t "general.objectives"}}
+ + {{o.courseTitle}} + + {{o.sessionCount}}{{o.instructorsCount}}{{o.objectiveCount}}
+
+{{/unless}} \ No newline at end of file diff --git a/packages/frontend/app/components/reports/curriculum/session-objectives.js b/packages/frontend/app/components/reports/curriculum/session-objectives.js new file mode 100644 index 0000000000..abf0e5d24f --- /dev/null +++ b/packages/frontend/app/components/reports/curriculum/session-objectives.js @@ -0,0 +1,165 @@ +import Component from '@glimmer/component'; +import { service } from '@ember/service'; +import striptags from 'striptags'; +import PapaParse from 'papaparse'; +import { dropTask, timeout } from 'ember-concurrency'; +import createDownloadFile from 'frontend/utils/create-download-file'; +import { DateTime } from 'luxon'; +import { cached } from '@glimmer/tracking'; +import { TrackedAsyncData } from 'ember-async-data'; + +export default class ReportsCurriculumSessionObjectivesComponent extends Component { + @service router; + @service intl; + @service graphql; + + @cached + get reportResultsData() { + const courseIds = this.args.courses.map((c) => c.id); + const filters = [`ids: [${courseIds.join(', ')}]`]; + const userData = ['id', 'firstName', 'lastName', 'middleName', 'displayName'].join(', '); + const sessionData = [ + 'id', + 'title', + 'sessionType { title }', + 'sessionObjectives { id, title }', + `offerings { id, startDate, endDate, instructors { ${userData} }, instructorGroups { id, users { ${userData} } } }`, + `ilmSession { id, dueDate, hours, instructors { ${userData} }, instructorGroups { id, users { ${userData} } } }`, + ].join(', '); + + const data = ['id', 'title', 'year', `sessions { ${sessionData} }`]; + return new TrackedAsyncData(this.graphql.find('courses', filters, data.join(', '))); + } + + get reportResults() { + if (!this.reportResultsData.isResolved) { + return []; + } + return this.reportResultsData.value.data.courses; + } + + get reportRunning() { + return this.reportResultsData.isPending; + } + + get reportWithInstructors() { + return this.reportResults.map((c) => { + c.sessions = c.sessions.map((s) => { + const offeringInstructors = s.offerings.map((o) => o.instructors.map((i) => i)).flat(); + const ilmInstructors = s.ilmSession?.instructors.map((i) => i) ?? []; + const offeringInstructorGroups = s.offerings.map((o) => o.instructorGroups).flat(); + const ilmInstructorGroups = s.ilmSession?.instructorGroups ?? []; + const instructorGroupInstructors = [...offeringInstructorGroups, ...ilmInstructorGroups] + .map((ig) => ig.users) + .flat(); + const instructors = [ + ...offeringInstructors, + ...ilmInstructors, + ...instructorGroupInstructors, + ].map((i) => this.getUserName(i)); + + s.instructors = [...new Set(instructors)].sort(); + return s; + }); + + return c; + }); + } + + get summary() { + return this.reportWithInstructors.map((c) => { + return { + courseId: c.id, + courseTitle: c.title, + sessionCount: c.sessions.length, + objectiveCount: c.sessions.reduce((acc, s) => acc + s.sessionObjectives.length, 0), + instructorsCount: c.sessions.reduce((acc, s) => acc + s.instructors.length, 0), + }; + }); + } + + getUserName = (user) => { + if (user.displayName) { + return user.displayName; + } + const middleInitial = user.middleName ? user.middleName.charAt(0) : false; + if (middleInitial) { + return `${user.firstName} ${middleInitial}. ${user.lastName}`; + } else { + return `${user.firstName} ${user.lastName}`; + } + }; + + get results() { + const origin = window.location.origin; + return this.reportWithInstructors.reduce((acc, c) => { + c.sessions.forEach((s) => { + const path = this.router.urlFor('session', c.id, s.id); + let firstOfferingDate, duration; + const firstOffering = s.offerings.sort( + (a, b) => DateTime.fromISO(a.startDate) - DateTime.fromISO(b.startDate), + )[0]; + if (firstOffering) { + firstOfferingDate = firstOffering.startDate; + duration = DateTime.fromISO(firstOffering.endDate).diff( + DateTime.fromISO(firstOffering.startDate), + 'hours', + ).hours; + } else if (s.ilmSession) { + firstOfferingDate = s.ilmSession.dueDate; + duration = s.ilmSession.hours; + } + s.sessionObjectives.forEach((o) => { + const title = striptags(o.title); + acc.push({ + courseId: c.id, + courseTitle: c.title, + sessionTitle: s.title, + sessionType: s.sessionType.title, + title, + link: `${origin}${path}`, + instructors: s.instructors, + firstOfferingDate: firstOfferingDate + ? DateTime.fromISO(firstOfferingDate).toLocaleString(this.intl.primarlyLocale) + : '', + duration: duration?.toFixed(2) ?? 0, + }); + }); + }); + return acc; + }, []); + } + + get sortedResults() { + return this.results.sort(this.sortResults); + } + + sortResults = (a, b) => { + if (a.courseTitle !== b.courseTitle) { + return a.courseTitle.localeCompare(b.courseTitle); + } + + return a.sessionTitle.localeCompare(b.sessionTitle); + }; + + downloadReport = dropTask(async () => { + const data = this.sortedResults.map((o) => { + const rhett = {}; + rhett[this.intl.t('general.course')] = o.courseTitle; + rhett[this.intl.t('general.session')] = o.sessionTitle; + rhett[this.intl.t('general.sessionType')] = o.sessionType; + rhett[this.intl.t('general.objective')] = o.title; + rhett[this.intl.t('general.instructors')] = o.instructors.join(', '); + rhett[this.intl.t('general.firstOffering')] = o.firstOfferingDate; + rhett[this.intl.t('general.hours')] = o.duration; + rhett[this.intl.t('general.link')] = o.link; + + return rhett; + }); + const csv = PapaParse.unparse(data); + this.finishedBuildingReport = true; + createDownloadFile(`objectives.csv`, csv, 'text/csv'); + await timeout(2000); + this.finishedBuildingReport = false; + }); +} diff --git a/packages/frontend/app/components/reports/list.hbs b/packages/frontend/app/components/reports/list.hbs deleted file mode 100644 index 6c910fdaab..0000000000 --- a/packages/frontend/app/components/reports/list.hbs +++ /dev/null @@ -1,77 +0,0 @@ -
-
- -
-
-
-
-

- {{t "general.reports"}} - ({{this.filteredReports.length}}) -

-
- -
-
-
- {{#if @showNewReportForm}} - - {{/if}} - {{#if this.newReport}} -
- - - {{this.newReport.title}} - - {{t "general.savedSuccessfully"}} -
- {{/if}} -
- {{#if @runningSubjectReport}} - - {{else}} -
- {{#if (and this.subjectReportObjects this.subjectReportObjects.isResolved)}} - {{#if this.reportsCount}} - - {{/if}} - {{else}} - - {{/if}} -
- {{/if}} -
\ No newline at end of file diff --git a/packages/frontend/app/components/reports/root.hbs b/packages/frontend/app/components/reports/root.hbs deleted file mode 100644 index 396f155ca4..0000000000 --- a/packages/frontend/app/components/reports/root.hbs +++ /dev/null @@ -1,12 +0,0 @@ -
- -
\ No newline at end of file diff --git a/packages/frontend/app/components/reports/subject.hbs b/packages/frontend/app/components/reports/subject.hbs index 405a4496de..e607da26fc 100644 --- a/packages/frontend/app/components/reports/subject.hbs +++ b/packages/frontend/app/components/reports/subject.hbs @@ -1,10 +1,5 @@ {{#if this.allSchools.isResolved}}
-
- - {{t "general.backToReports"}} - -
{{#let (load this.reportDescriptionPromise) as |p|}} {{#if p.isResolved}} +
+
+ +
+
+
+
+

+ {{t "general.reports"}} + ({{this.filteredReports.length}}) +

+
+ +
+
+
+ {{#if @showNewReportForm}} + + {{/if}} + {{#if this.newReport}} +
+ + + {{this.newReport.title}} + + {{t "general.savedSuccessfully"}} +
+ {{/if}} +
+ {{#if @runningSubjectReport}} + + {{else}} +
+ {{#if (and this.subjectReportObjects this.subjectReportObjects.isResolved)}} + {{#if this.reportsCount}} + + {{/if}} + {{else}} + + {{/if}} +
+ {{/if}} +
+ \ No newline at end of file diff --git a/packages/frontend/app/components/reports/list.js b/packages/frontend/app/components/reports/subjects-list.js similarity index 95% rename from packages/frontend/app/components/reports/list.js rename to packages/frontend/app/components/reports/subjects-list.js index eb968013f4..95e8e2b6b4 100644 --- a/packages/frontend/app/components/reports/list.js +++ b/packages/frontend/app/components/reports/subjects-list.js @@ -5,7 +5,7 @@ import { dropTask, restartableTask } from 'ember-concurrency'; import { TrackedAsyncData } from 'ember-async-data'; import { action } from '@ember/object'; -export default class ReportsListComponent extends Component { +export default class ReportsSubjectsListComponent extends Component { @service store; @service currentUser; @service reporting; @@ -134,8 +134,8 @@ export default class ReportsListComponent extends Component { ); @action - toggleNewReportForm() { + createNewReport(type) { this.args.setRunningSubjectReport(null); - this.args.toggleNewReportForm; + this.args[`setShowNew${type}ReportForm`](true); } } diff --git a/packages/frontend/app/components/reports/switcher.hbs b/packages/frontend/app/components/reports/switcher.hbs new file mode 100644 index 0000000000..744e0277e7 --- /dev/null +++ b/packages/frontend/app/components/reports/switcher.hbs @@ -0,0 +1,8 @@ +
+ + {{t "general.subjectReports"}} + + + {{t "general.curriculumReports"}} + +
\ No newline at end of file diff --git a/packages/frontend/app/components/reports/table-row.hbs b/packages/frontend/app/components/reports/table-row.hbs index e562689515..5cab937372 100644 --- a/packages/frontend/app/components/reports/table-row.hbs +++ b/packages/frontend/app/components/reports/table-row.hbs @@ -5,7 +5,7 @@ > {{#if (eq @decoratedReport.type "subject")}} - + {{@decoratedReport.title}} {{/if}} diff --git a/packages/frontend/app/controllers/reports/curriculum.js b/packages/frontend/app/controllers/reports/curriculum.js new file mode 100644 index 0000000000..e794173d54 --- /dev/null +++ b/packages/frontend/app/controllers/reports/curriculum.js @@ -0,0 +1,21 @@ +import Controller from '@ember/controller'; +import { tracked } from '@glimmer/tracking'; + +export default class ReportsCurriculumController extends Controller { + queryParams = [{ courses: 'courses' }, { report: 'report' }]; + + @tracked courses = null; + + get selectedCourseIds() { + return this.courses?.split('-'); + } + + setSelectedCourseIds = (ids) => { + if (!ids || !ids.length) { + this.courses = null; + } else { + //use a Set to remove duplicates + this.courses = [...new Set(ids)].join('-'); + } + }; +} diff --git a/packages/frontend/app/controllers/subject-report.js b/packages/frontend/app/controllers/reports/subject.js similarity index 100% rename from packages/frontend/app/controllers/subject-report.js rename to packages/frontend/app/controllers/reports/subject.js diff --git a/packages/frontend/app/controllers/reports.js b/packages/frontend/app/controllers/reports/subjects.js similarity index 92% rename from packages/frontend/app/controllers/reports.js rename to packages/frontend/app/controllers/reports/subjects.js index e9ca720ace..d8ad316016 100644 --- a/packages/frontend/app/controllers/reports.js +++ b/packages/frontend/app/controllers/reports/subjects.js @@ -3,7 +3,7 @@ import { tracked } from '@glimmer/tracking'; import { restartableTask, timeout } from 'ember-concurrency'; import { action } from '@ember/object'; -export default class ReportsController extends Controller { +export default class ReportsSubjectsController extends Controller { queryParams = [ { sortReportsBy: 'sortBy' }, { titleFilter: 'filter' }, diff --git a/packages/frontend/app/router.js b/packages/frontend/app/router.js index b585ab7eaf..75a7a64622 100644 --- a/packages/frontend/app/router.js +++ b/packages/frontend/app/router.js @@ -77,6 +77,9 @@ Router.map(function () { path: 'data/programyears/:program_year_id/objectives', }); this.route('search'); - this.route('reports'); - this.route('subject-report', { path: 'reports/subjects/:report_id' }); + this.route('reports', function () { + this.route('curriculum'); + this.route('subjects'); + this.route('subject', { path: 'subjects/:report_id' }); + }); }); diff --git a/packages/frontend/app/routes/reports.js b/packages/frontend/app/routes/reports.js index 5e05cbfba1..22e9df4675 100644 --- a/packages/frontend/app/routes/reports.js +++ b/packages/frontend/app/routes/reports.js @@ -1,10 +1,3 @@ import Route from '@ember/routing/route'; -import { service } from '@ember/service'; -export default class ReportsRoute extends Route { - @service session; - - beforeModel(transition) { - this.session.requireAuthentication(transition, 'login'); - } -} +export default class ReportsRoute extends Route {} diff --git a/packages/frontend/app/routes/reports/curriculum.js b/packages/frontend/app/routes/reports/curriculum.js new file mode 100644 index 0000000000..c0f6b9856d --- /dev/null +++ b/packages/frontend/app/routes/reports/curriculum.js @@ -0,0 +1,48 @@ +import Route from '@ember/routing/route'; +import { service } from '@ember/service'; +import { DateTime } from 'luxon'; + +export default class ReportsCurriculumRoute extends Route { + @service session; + @service store; + @service graphql; + @service currentUser; + + beforeModel(transition) { + this.session.requireAuthentication(transition, 'login'); + } + + async model() { + const schools = await this.store.findAll('school'); + const threeYearsAgo = DateTime.now().year - 3; + // Limit query to surounding years + const years = [...Array(7).keys()].map((i) => threeYearsAgo + i); + const result = await this.graphql.find( + 'courses', + [`academicYears: [${years.join(', ')}]`], + 'id, title, year, externalId', + ); + const allCourseData = result.data.courses; + + return schools.map((school) => { + const courseIds = school.hasMany('courses').ids(); + const courses = allCourseData.filter((course) => courseIds.includes(course.id)); + const years = courses.map(({ year }) => year); + const uniqueYears = [...new Set(years)].sort().reverse(); + return { + id: school.id, + title: school.title, + years: uniqueYears.map((year) => { + return { + year, + courses: courses.filter((course) => course.year === year), + }; + }), + }; + }); + } + + async afterModel() { + return this.currentUser.getModel(); + } +} diff --git a/packages/frontend/app/routes/reports/index.js b/packages/frontend/app/routes/reports/index.js new file mode 100644 index 0000000000..d9e5c4ae06 --- /dev/null +++ b/packages/frontend/app/routes/reports/index.js @@ -0,0 +1,12 @@ +import Route from '@ember/routing/route'; +import { service } from '@ember/service'; + +export default class ReportsIndexRoute extends Route { + @service session; + @service router; + + beforeModel(transition) { + this.session.requireAuthentication(transition, 'login'); + this.router.replaceWith('reports.subjects'); + } +} diff --git a/packages/frontend/app/routes/subject-report.js b/packages/frontend/app/routes/reports/subject.js similarity index 100% rename from packages/frontend/app/routes/subject-report.js rename to packages/frontend/app/routes/reports/subject.js index 456e98c243..f1edb372c5 100644 --- a/packages/frontend/app/routes/subject-report.js +++ b/packages/frontend/app/routes/reports/subject.js @@ -1,5 +1,5 @@ -import { service } from '@ember/service'; import Route from '@ember/routing/route'; +import { service } from '@ember/service'; export default class ReportsSubjectRoute extends Route { @service reporting; diff --git a/packages/frontend/app/routes/reports/subjects.js b/packages/frontend/app/routes/reports/subjects.js new file mode 100644 index 0000000000..88d251f8e4 --- /dev/null +++ b/packages/frontend/app/routes/reports/subjects.js @@ -0,0 +1,3 @@ +import Route from '@ember/routing/route'; + +export default class ReportsSubjectsRoute extends Route {} diff --git a/packages/frontend/app/styles/components.scss b/packages/frontend/app/styles/components.scss index 220fdb8370..7a6aaf7d80 100644 --- a/packages/frontend/app/styles/components.scss +++ b/packages/frontend/app/styles/components.scss @@ -139,11 +139,16 @@ @forward "components/curriculum-inventory/verification-preview-table7"; @forward "components/curriculum-inventory/verification-preview-table8"; +@forward "components/reports/choose-course"; +@forward "components/reports/choose-new-report"; +@forward "components/reports/curriculum"; +@forward "components/reports/curriculum-loading"; @forward "components/reports/new-subject"; +@forward "components/reports/switcher"; @forward "components/reports/subject"; @forward "components/reports/subject-header"; @forward "components/reports/subjects"; -@forward "components/reports/root"; +@forward "components/reports/subjects-list"; @forward "components/reports/list-loading"; @forward "components/school/session-type-visualize-vocabularies"; diff --git a/packages/frontend/app/styles/components/reports/choose-course.scss b/packages/frontend/app/styles/components/reports/choose-course.scss new file mode 100644 index 0000000000..1fc0d28df6 --- /dev/null +++ b/packages/frontend/app/styles/components/reports/choose-course.scss @@ -0,0 +1,19 @@ +@use "../../ilios-common/mixins" as cm; + +.reports-choose-course { + .schools { + margin: 0 0 1em 0; + } + button { + @include cm.ilios-button-reset; + font-weight: bold; + } + + ul { + @include cm.ilios-list-reset; + + li { + margin-left: 0.5em; + } + } +} diff --git a/packages/frontend/app/styles/components/reports/choose-new-report.scss b/packages/frontend/app/styles/components/reports/choose-new-report.scss new file mode 100644 index 0000000000..cafedd14d2 --- /dev/null +++ b/packages/frontend/app/styles/components/reports/choose-new-report.scss @@ -0,0 +1,60 @@ +@use "../../ilios-common/colors" as c; + +@use "sass:color"; + +.choose-new-report { + margin: 0 0.5rem; + position: relative; + text-align: right; + + button { + background-color: transparent; + border: 1px solid c.$slightWhite; + border-radius: 0.2rem; + color: c.$raisinBlack; + font-weight: normal; + padding: 0.25rem 0.5rem; + } + + .menu { + background-color: c.$slightWhite; + box-shadow: 0 2px 2px color.adjust(c.$black, $alpha: 0.8); + display: flex; + flex-direction: column; + list-style-type: none; + margin: 0; + padding: 0; + position: absolute; + top: 1.6rem; + right: 0; + z-index: 100; + + button { + border: 0; + background-color: c.$slightWhite; + color: c.$raisinBlack; + display: block; + outline: none; + padding: 0.5rem 1rem; + text-align: right; + text-decoration: none; + white-space: nowrap; + + &:hover, + &:focus { + background-color: c.$tealBlue; + color: c.$white; + } + } + } + + .toggle { + background-color: c.$tealBlue; + color: c.$white; + + &[aria-expanded="true"] { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } + } +} diff --git a/packages/frontend/app/styles/components/reports/curriculum-loading.scss b/packages/frontend/app/styles/components/reports/curriculum-loading.scss new file mode 100644 index 0000000000..835a15679c --- /dev/null +++ b/packages/frontend/app/styles/components/reports/curriculum-loading.scss @@ -0,0 +1,11 @@ +@use "../../ilios-common/mixins" as cm; +@use "sass:color"; + +.reports-curriculum-loading { + @include cm.loading-text; + @include cm.loading-shimmer; + + select { + @include cm.loading-text; + } +} diff --git a/packages/frontend/app/styles/components/reports/curriculum.scss b/packages/frontend/app/styles/components/reports/curriculum.scss new file mode 100644 index 0000000000..5a29232e1d --- /dev/null +++ b/packages/frontend/app/styles/components/reports/curriculum.scss @@ -0,0 +1,37 @@ +@use "../../ilios-common/colors" as c; +@use "../../ilios-common/mixins" as cm; +@use "sass:color"; + +.reports-curriculum { + @include cm.main-section; + + .input-buttons { + display: flex; + width: 100%; + margin-top: 0.5rem; + justify-content: flex-end; + button { + margin-left: 0.5rem; + + &:disabled { + cursor: default; + background-color: c.$davysGrey; + } + } + } + + table { + width: 100%; + caption { + @include cm.ilios-heading-h5; + } + th, + td { + text-align: left; + } + } + + .run { + @include cm.font-size("medium"); + } +} diff --git a/packages/frontend/app/styles/components/reports/root.scss b/packages/frontend/app/styles/components/reports/subjects-list.scss similarity index 96% rename from packages/frontend/app/styles/components/reports/root.scss rename to packages/frontend/app/styles/components/reports/subjects-list.scss index 6135b39f51..fc07b0abc6 100644 --- a/packages/frontend/app/styles/components/reports/root.scss +++ b/packages/frontend/app/styles/components/reports/subjects-list.scss @@ -1,6 +1,6 @@ @use "../../ilios-common/mixins" as m; -.reports-root { +.reports-subjects-list { @include m.main-section; .filters { diff --git a/packages/frontend/app/styles/components/reports/switcher.scss b/packages/frontend/app/styles/components/reports/switcher.scss new file mode 100644 index 0000000000..4121d6b9c2 --- /dev/null +++ b/packages/frontend/app/styles/components/reports/switcher.scss @@ -0,0 +1,28 @@ +@use "../../ilios-common/mixins" as cm; +@use "../../ilios-common/colors" as c; + +.reports-switcher { + display: flex; + justify-content: center; + margin: 1em; + a { + @include cm.ilios-link-button; + background-color: c.$white; + color: c.$tealBlue; + border: 1px solid rgba(c.$black, 0.2); + @include cm.font-size("medium"); + font-weight: 600; + padding: 0.25em 0.5em; + text-align: center; + text-shadow: none; + + &:hover { + cursor: pointer; + } + + &.active { + background-color: c.$tealBlue; + color: c.$white; + } + } +} diff --git a/packages/frontend/app/templates/reports.hbs b/packages/frontend/app/templates/reports.hbs index 43bef86f8f..da8d892494 100644 --- a/packages/frontend/app/templates/reports.hbs +++ b/packages/frontend/app/templates/reports.hbs @@ -1,11 +1,3 @@ {{page-title (t "general.reports")}} - \ No newline at end of file + +{{outlet}} \ No newline at end of file diff --git a/packages/frontend/app/templates/reports/curriculum-loading.hbs b/packages/frontend/app/templates/reports/curriculum-loading.hbs new file mode 100644 index 0000000000..b1f3c329bb --- /dev/null +++ b/packages/frontend/app/templates/reports/curriculum-loading.hbs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/frontend/app/templates/reports/curriculum.hbs b/packages/frontend/app/templates/reports/curriculum.hbs new file mode 100644 index 0000000000..b31c99ffe3 --- /dev/null +++ b/packages/frontend/app/templates/reports/curriculum.hbs @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/packages/frontend/app/templates/subject-report.hbs b/packages/frontend/app/templates/reports/subject.hbs similarity index 100% rename from packages/frontend/app/templates/subject-report.hbs rename to packages/frontend/app/templates/reports/subject.hbs diff --git a/packages/frontend/app/templates/reports/subjects.hbs b/packages/frontend/app/templates/reports/subjects.hbs new file mode 100644 index 0000000000..d53872805b --- /dev/null +++ b/packages/frontend/app/templates/reports/subjects.hbs @@ -0,0 +1,10 @@ + \ No newline at end of file diff --git a/packages/frontend/tests/acceptance/reports/subjects-test.js b/packages/frontend/tests/acceptance/reports/subjects-test.js index 8aab7bdc74..1f5cbe771e 100644 --- a/packages/frontend/tests/acceptance/reports/subjects-test.js +++ b/packages/frontend/tests/acceptance/reports/subjects-test.js @@ -65,7 +65,7 @@ module('Acceptance | Reports - Subject Reports', function (hooks) { test('visiting /reports', async function (assert) { await page.visit(); - assert.strictEqual(currentRouteName(), 'reports'); + assert.strictEqual(currentRouteName(), 'reports.index'); }); test('shows reports', async function (assert) { @@ -90,7 +90,8 @@ module('Acceptance | Reports - Subject Reports', function (hooks) { ); assert.strictEqual(page.root.list.table.reports[1].title, 'my report 0'); assert.ok(page.root.list.newReportLinkIsHidden); - await page.root.list.toggleNewSubjectReportForm(); + await page.root.list.chooser.toggle.click(); + await page.root.list.chooser.subject.click(); await page.root.list.newSubject.title.set('aardvark'); await page.root.list.newSubject.schools.choose('1'); await page.root.list.newSubject.subjects.choose('session'); @@ -139,7 +140,8 @@ module('Acceptance | Reports - Subject Reports', function (hooks) { await page.visit(); assert.strictEqual(page.root.list.table.reports.length, 2); assert.ok(page.root.list.newReportLinkIsHidden); - await page.root.list.toggleNewSubjectReportForm(); + await page.root.list.chooser.toggle.click(); + await page.root.list.chooser.subject.click(); await page.root.list.newSubject.schools.choose('1'); await page.root.list.newSubject.subjects.choose('session'); await page.root.list.newSubject.save(); @@ -158,7 +160,8 @@ module('Acceptance | Reports - Subject Reports', function (hooks) { 'All Sessions for term 0 in school 0', ); assert.strictEqual(page.root.list.table.reports[1].title, 'my report 0'); - await page.root.list.toggleNewSubjectReportForm(); + await page.root.list.chooser.toggle.click(); + await page.root.list.chooser.subject.click(); await page.root.list.newSubject.schools.choose('1'); await page.root.list.newSubject.subjects.choose('term'); await page.root.list.newSubject.objects.choose('session'); @@ -208,7 +211,8 @@ module('Acceptance | Reports - Subject Reports', function (hooks) { 'All Sessions for term 0 in school 0', ); assert.strictEqual(page.root.list.table.reports[1].title, 'my report 0'); - await page.root.list.toggleNewSubjectReportForm(); + await page.root.list.chooser.toggle.click(); + await page.root.list.chooser.subject.click(); await page.root.list.newSubject.schools.choose('1'); await page.root.list.newSubject.subjects.choose('course'); await page.root.list.newSubject.objects.choose('mesh term'); @@ -264,7 +268,8 @@ module('Acceptance | Reports - Subject Reports', function (hooks) { 'All Sessions for term 0 in school 0', ); assert.strictEqual(page.root.list.table.reports[1].title, 'my report 0'); - await page.root.list.toggleNewSubjectReportForm(); + await page.root.list.chooser.toggle.click(); + await page.root.list.chooser.subject.click(); await page.root.list.newSubject.schools.choose('1'); await page.root.list.newSubject.subjects.choose('term'); await page.root.list.newSubject.objects.choose('program year'); @@ -293,7 +298,8 @@ module('Acceptance | Reports - Subject Reports', function (hooks) { 'All Sessions for term 0 in school 0', ); assert.strictEqual(page.root.list.table.reports[1].title, 'my report 0'); - await page.root.list.toggleNewSubjectReportForm(); + await page.root.list.chooser.toggle.click(); + await page.root.list.chooser.subject.click(); await page.root.list.newSubject.schools.choose('All Schools'); await page.root.list.newSubject.subjects.choose('course'); await page.root.list.newSubject.save(); @@ -344,7 +350,8 @@ module('Acceptance | Reports - Subject Reports', function (hooks) { test('run subject report', async function (assert) { assert.expect(5); await page.visit(); - await page.root.list.toggleNewSubjectReportForm(); + await page.root.list.chooser.toggle.click(); + await page.root.list.chooser.subject.click(); await page.root.list.newSubject.schools.choose('1'); await page.root.list.newSubject.subjects.choose('session'); await page.root.list.newSubject.objects.choose('course'); @@ -369,7 +376,7 @@ module('Acceptance | Reports - Subject Reports', function (hooks) { }); await page.root.list.newSubject.run(); await percySnapshot(assert); - assert.strictEqual(currentURL(), '/reports?showNewReportForm=true'); + assert.strictEqual(currentURL(), '/reports?showNewSubjectReportForm=true'); assert.strictEqual( page.root.results.description, 'This report shows all Sessions associated with Course "course 0" (2015) in school 0.', @@ -381,7 +388,8 @@ module('Acceptance | Reports - Subject Reports', function (hooks) { test('reset year when subject report is run', async function (assert) { assert.expect(12); await page.visit(); - await page.root.list.toggleNewSubjectReportForm(); + await page.root.list.chooser.toggle.click(); + await page.root.list.chooser.subject.click(); await page.root.list.newSubject.schools.choose('1'); await page.root.list.newSubject.subjects.choose('course'); this.server.post('api/graphql', ({ db }, { requestBody }) => { @@ -459,7 +467,8 @@ module('Acceptance | Reports - Subject Reports', function (hooks) { await page.visit(); assert.strictEqual(page.root.list.table.reports.length, 2); assert.ok(page.root.list.newReportLinkIsHidden); - await page.root.list.toggleNewSubjectReportForm(); + await page.root.list.chooser.toggle.click(); + await page.root.list.chooser.subject.click(); await page.root.list.newSubject.schools.choose('1'); await page.root.list.newSubject.subjects.choose('instructor'); await page.root.list.newSubject.objects.choose('academic year'); @@ -504,7 +513,8 @@ module('Acceptance | Reports - Subject Reports', function (hooks) { test('courses by academic year hides year', async function (assert) { assert.expect(5); await page.visit(); - await page.root.list.toggleNewSubjectReportForm(); + await page.root.list.chooser.toggle.click(); + await page.root.list.chooser.subject.click(); await page.root.list.newSubject.schools.choose(''); await page.root.list.newSubject.subjects.choose('course'); await page.root.list.newSubject.objects.choose('academic year'); diff --git a/packages/frontend/tests/integration/components/reports/choose-course-test.js b/packages/frontend/tests/integration/components/reports/choose-course-test.js new file mode 100644 index 0000000000..0a8151ef2d --- /dev/null +++ b/packages/frontend/tests/integration/components/reports/choose-course-test.js @@ -0,0 +1,14 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'frontend/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | reports/choose-course', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + await render(hbs``); + + assert.dom().hasText(''); + }); +}); diff --git a/packages/frontend/tests/integration/components/reports/curriculum-test.js b/packages/frontend/tests/integration/components/reports/curriculum-test.js new file mode 100644 index 0000000000..d974c504b2 --- /dev/null +++ b/packages/frontend/tests/integration/components/reports/curriculum-test.js @@ -0,0 +1,14 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'frontend/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | reports/curriculum', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + await render(hbs``); + + assert.dom().hasText(''); + }); +}); diff --git a/packages/frontend/tests/integration/components/reports/curriculum/learner-groups-test.js b/packages/frontend/tests/integration/components/reports/curriculum/learner-groups-test.js new file mode 100644 index 0000000000..926a950066 --- /dev/null +++ b/packages/frontend/tests/integration/components/reports/curriculum/learner-groups-test.js @@ -0,0 +1,14 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'frontend/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | reports/curriculum/learner-groups', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + await render(hbs``); + + assert.dom().hasText(''); + }); +}); diff --git a/packages/frontend/tests/integration/components/reports/curriculum/loading-test.js b/packages/frontend/tests/integration/components/reports/curriculum/loading-test.js new file mode 100644 index 0000000000..da7cbb023d --- /dev/null +++ b/packages/frontend/tests/integration/components/reports/curriculum/loading-test.js @@ -0,0 +1,14 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'frontend/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | reports/curriculum/loading', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + await render(hbs``); + + assert.dom().hasText(''); + }); +}); diff --git a/packages/frontend/tests/integration/components/reports/curriculum/result-buttons-test.js b/packages/frontend/tests/integration/components/reports/curriculum/result-buttons-test.js new file mode 100644 index 0000000000..cba56b9aca --- /dev/null +++ b/packages/frontend/tests/integration/components/reports/curriculum/result-buttons-test.js @@ -0,0 +1,14 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'frontend/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | reports/curriculum/result-buttons', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + await render(hbs``); + + assert.dom().hasText(''); + }); +}); diff --git a/packages/frontend/tests/integration/components/reports/curriculum/session-objectives-test.js b/packages/frontend/tests/integration/components/reports/curriculum/session-objectives-test.js new file mode 100644 index 0000000000..ff93a4bb3f --- /dev/null +++ b/packages/frontend/tests/integration/components/reports/curriculum/session-objectives-test.js @@ -0,0 +1,14 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'frontend/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | reports/curriculum/session-objectives', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + await render(hbs``); + + assert.dom().hasText(''); + }); +}); diff --git a/packages/frontend/tests/integration/components/reports/root-test.js b/packages/frontend/tests/integration/components/reports/root-test.js deleted file mode 100644 index 3d1198d179..0000000000 --- a/packages/frontend/tests/integration/components/reports/root-test.js +++ /dev/null @@ -1,80 +0,0 @@ -import { module, test } from 'qunit'; -import { setupRenderingTest } from 'frontend/tests/helpers'; -import { render } from '@ember/test-helpers'; -import { hbs } from 'ember-cli-htmlbars'; -import { setupMirage } from 'frontend/tests/test-support/mirage'; -import { setupAuthentication } from 'ilios-common'; -import { component } from 'frontend/tests/pages/components/reports/root'; -import a11yAudit from 'ember-a11y-testing/test-support/audit'; - -module('Integration | Component | reports/root', function (hooks) { - setupRenderingTest(hooks); - setupMirage(hooks); - - hooks.beforeEach(async function () { - this.user = await setupAuthentication(); - }); - - test('it renders', async function (assert) { - this.server.create('report', { - title: null, - subject: 'course', - user: this.user, - }); - this.server.create('report', { - title: null, - subject: 'session', - user: this.user, - }); - - await render(hbs``); - - assert.strictEqual(component.list.table.reports.length, 2); - assert.strictEqual(component.list.table.reports[0].title, 'All Courses in All Schools'); - assert.strictEqual(component.list.table.reports[1].title, 'All Sessions in All Schools'); - - await a11yAudit(this.element); - assert.ok(true, 'no a11y errors found!'); - }); - - test('it renders empty', async function (assert) { - await render(hbs``); - assert.notOk(component.list.table.isVisible); - a11yAudit(this.element); - }); - - test('toggle new report form', async function (assert) { - this.set('showNewReportForm', false); - this.set('toggleNewReportForm', () => { - this.set('showNewReportForm', !this.showNewReportForm); - }); - - await render(hbs``); - assert.notOk(component.list.newSubject.isVisible); - await component.list.toggleNewSubjectReportForm(); - assert.ok(component.list.newSubject.isVisible); - await component.list.toggleNewSubjectReportForm(); - assert.notOk(component.list.newSubject.isVisible); - }); -}); diff --git a/packages/frontend/tests/integration/components/reports/list-test.js b/packages/frontend/tests/integration/components/reports/subjects-list-test.js similarity index 72% rename from packages/frontend/tests/integration/components/reports/list-test.js rename to packages/frontend/tests/integration/components/reports/subjects-list-test.js index 1813301f44..5a899d2612 100644 --- a/packages/frontend/tests/integration/components/reports/list-test.js +++ b/packages/frontend/tests/integration/components/reports/subjects-list-test.js @@ -7,7 +7,7 @@ import { setupAuthentication } from 'ilios-common'; import { component } from 'frontend/tests/pages/components/reports/list'; import a11yAudit from 'ember-a11y-testing/test-support/audit'; -module('Integration | Component | reports/list', function (hooks) { +module('Integration | Component | reports/subjects-list', function (hooks) { setupRenderingTest(hooks); setupMirage(hooks); @@ -27,7 +27,7 @@ module('Integration | Component | reports/list', function (hooks) { user: this.user, }); - await render(hbs` { - this.set('showNewReportForm', !this.showNewReportForm); + assert.expect(5); + this.set('showNewSubjectReportForm', false); + this.set('setShowNewSubjectReportForm', (val) => { + assert.deepEqual(!this.showNewSubjectReportForm, val); + this.set('showNewSubjectReportForm', val); }); - await render(hbs``); assert.notOk(component.newSubject.isVisible); - await component.toggleNewSubjectReportForm(); + await component.chooser.toggle.click(); + await component.chooser.subject.click(); assert.ok(component.newSubject.isVisible); - await component.toggleNewSubjectReportForm(); + await component.newSubject.cancel(); assert.notOk(component.newSubject.isVisible); }); }); diff --git a/packages/frontend/tests/integration/components/reports/switcher-test.js b/packages/frontend/tests/integration/components/reports/switcher-test.js new file mode 100644 index 0000000000..5a16b2eb53 --- /dev/null +++ b/packages/frontend/tests/integration/components/reports/switcher-test.js @@ -0,0 +1,14 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'frontend/tests/helpers'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | reports/switcher', function (hooks) { + setupRenderingTest(hooks); + + test('it renders', async function (assert) { + await render(hbs``); + + assert.dom().hasText(''); + }); +}); diff --git a/packages/frontend/tests/pages/components/reports/choose-new-report.js b/packages/frontend/tests/pages/components/reports/choose-new-report.js new file mode 100644 index 0000000000..5f7cef0d93 --- /dev/null +++ b/packages/frontend/tests/pages/components/reports/choose-new-report.js @@ -0,0 +1,25 @@ +import { create, collection, triggerable } from 'ember-cli-page-object'; +import { hasFocus } from 'ilios-common'; +export default create({ + scope: '[data-test-choose-new-report]', + toggle: { + scope: '[data-test-toggle]', + enter: triggerable('keyup', '', { eventProperties: { key: 'Enter' } }), + down: triggerable('keyup', '', { eventProperties: { key: 'ArrowDown' } }), + esc: triggerable('keyup', '', { eventProperties: { key: 'Escape' } }), + hasFocus: hasFocus(), + }, + types: collection('[data-test-item]', { + hasFocus: hasFocus(), + mouseEnter: triggerable('mouseenter'), + down: triggerable('keyup', '', { eventProperties: { key: 'ArrowDown' } }), + esc: triggerable('keyup', '', { eventProperties: { key: 'Escape' } }), + left: triggerable('keyup', '', { eventProperties: { key: 'ArrowLeft' } }), + right: triggerable('keyup', '', { eventProperties: { key: 'ArrowRight' } }), + tab: triggerable('keyup', '', { eventProperties: { key: 'Tab' } }), + up: triggerable('keyup', '', { eventProperties: { key: 'ArrowUp' } }), + }), + subject: { + scope: '[data-test-item]:nth-of-type(1)', + }, +}); diff --git a/packages/frontend/tests/pages/components/reports/list.js b/packages/frontend/tests/pages/components/reports/list.js index 85e22a7212..18fd87a8c3 100644 --- a/packages/frontend/tests/pages/components/reports/list.js +++ b/packages/frontend/tests/pages/components/reports/list.js @@ -2,11 +2,12 @@ import { clickable, create, fillable, isHidden, text } from 'ember-cli-page-obje import { hasFocus } from 'ilios-common'; import table from './table'; import newSubject from './new-subject'; +import chooser from './choose-new-report'; const definition = { filterByTitle: fillable('[data-test-title-filter]'), headerTitle: text('[data-test-reports-header-title]'), - toggleNewSubjectReportForm: clickable('[data-test-expand-collapse-button] button'), + chooser, newSubject, newReportLink: text('[data-test-newly-saved-report] a'), newReportLinkIsHidden: isHidden('[data-test-newly-saved-report] a'), diff --git a/packages/frontend/tests/pages/reports.js b/packages/frontend/tests/pages/reports.js index 7b48444dae..14828eb96e 100644 --- a/packages/frontend/tests/pages/reports.js +++ b/packages/frontend/tests/pages/reports.js @@ -1,9 +1,9 @@ import { create, visitable } from 'ember-cli-page-object'; -import root from './components/reports/root'; +// import root from './components/reports/root'; const page = { visit: visitable('/reports'), - root, + // root, }; export default create(page); diff --git a/packages/frontend/tests/unit/controllers/reports-test.js b/packages/frontend/tests/unit/controllers/reports-test.js deleted file mode 100644 index 4387823580..0000000000 --- a/packages/frontend/tests/unit/controllers/reports-test.js +++ /dev/null @@ -1,17 +0,0 @@ -import { module, test } from 'qunit'; -import { setupTest } from 'frontend/tests/helpers'; -import Controller from 'frontend/controllers/reports'; - -module('Unit | Controller | reports', function (hooks) { - setupTest(hooks); - - hooks.beforeEach(function () { - this.owner.register('controller:reports', Controller); - }); - - // TODO: Replace this with your real tests. - test('it exists', function (assert) { - let controller = this.owner.lookup('controller:reports'); - assert.ok(controller); - }); -}); diff --git a/packages/frontend/translations/en-us.yaml b/packages/frontend/translations/en-us.yaml index 6f8a29348f..85a28450dd 100644 --- a/packages/frontend/translations/en-us.yaml +++ b/packages/frontend/translations/en-us.yaml @@ -99,6 +99,7 @@ general: curriculumInventoryReportRolloverSummary: "This action will copy the structure of this report to be used in your target year. It will not copy course information, only the higher level sequence block structure. You will need to add the appropriate course information and modify the sequence block start and end dates once you have rolled over the existing report structure." curriculumInventoryReportRolloverSuccess: Report-Rollover Completed Successfully curriculumInventoryReports: Curriculum Inventory Reports + curriculumReports: Curriculum Reports dashboard: Dashboard deactivate: Deactivate defaultInstructors: Default Instructors @@ -232,6 +233,7 @@ general: narrative: Narrative navigationCompleteText: Ilios page navigation is complete. You may now navigate the page content as you wish. newCourse: New Course + newCourseReport: New Course Report newCurriculumInventoryReport: New Curriculum Inventory Report newDomain: New Domain newInstructorGroup: New Instructor Group @@ -241,6 +243,7 @@ general: newReport: New Report newSchool: New School newSequenceBlock: New Sequence Block + newSubjectReport: New Subject Report newTerm: New Term newToken: New Token newUser: New User @@ -346,13 +349,16 @@ general: reportDisplayDescriptionWithoutObject: "This report shows all {subject} in {school}." reportDisplayTitleWithObject: "All {subject} for {object} in {school}" reportDisplayTitleWithoutObject: "All {subject} in {school}" + reportForCourses: report for {courseCount, plural, one {one course} other {# courses}} reportName: Report Name reportNamePlaceholder: Please enter a report name. reports: Reports reportTitle: Report Title requiredInTrack: Required In Track resourceTypes: Resource Types + resultsSummary: Results Summary root: Root + run: Run runReport: Run Report sampleFile: Sample File saved: New User Saved Successfully @@ -364,7 +370,9 @@ general: select: Select selectAcademicYear: Select Academic Year selectAllOrNone: Select All or None + selectCoursesToRunReport: Select Courses to Run Report selected: Selected + selectedCourses: Selected Courses selectProgram: Select a Program selectSchool: Select School selectUser: Select User @@ -377,6 +385,7 @@ general: sequenceBlockTitlePlaceholder: Please enter a title for this sequence block. sequenceNumber: "Sequence #" sessionAttributes: Session Attributes + sessionObjectives: Session Objectives sessionTitlePlaceholder: Enter a title for this session sessionTypeConfirmRemoval: Are you sure you want to delete this session type? This action cannot be undone. showResultsFor: Show Results For diff --git a/packages/frontend/translations/es.yaml b/packages/frontend/translations/es.yaml index 0b5a23ac91..43409eb2a1 100644 --- a/packages/frontend/translations/es.yaml +++ b/packages/frontend/translations/es.yaml @@ -232,6 +232,7 @@ general: narrative: narrativa navigationCompleteText: La navegación de la página de Ilios está completa. Ahora puede navegar por el contenido de la página como desee. newCourse: Curso Nuevo + newCourseReport: Nuevo Informe de Curso newCurriculumInventoryReport: Nuevo Informe de Inventario de Plan de Estudios newDomain: Nuevo Dominio newInstructorGroup: Nuevo Grupo de Instructores @@ -241,6 +242,7 @@ general: newReport: Nuevo Reporte newSchool: Nueva Escuela newSequenceBlock: Nuevo Bloque de Secuencia + newSubjectReport: Nuevo Informe de Tema newTerm: Nuevo Termino newToken: Nuevo Símbolo (Token) newUser: Nuevo Usuario diff --git a/packages/frontend/translations/fr.yaml b/packages/frontend/translations/fr.yaml index 84f4322fdb..ea5a7caf60 100644 --- a/packages/frontend/translations/fr.yaml +++ b/packages/frontend/translations/fr.yaml @@ -232,6 +232,7 @@ general: narrative: narrative navigationCompleteText: La navigation sur la page Ilios est terminée. Vous pouvez maintenant naviguer dans le contenu de la page comme vous le souhaitez. newCourse: Cours neuf + newCourseReport: nouveau rapport de cours newCurriculumInventoryReport: "Nouveau Rapport d'inventaire des Programmes d'études" newDomain: Domaine nouvelle newInstructorGroup: Groupe des Instructeurs nouveau @@ -240,6 +241,7 @@ general: newProgramYear: Année de diplôme neuf newReport: Nouveau rapport newSchool: École nouvelle + newSubjectReport: nouveau rapport thématique newSequenceBlock: Nouveau bloc de séquence newTerm: Nouveau terme newToken: Nouveau jeton