diff --git a/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/dashboard/terms-calendar-filter.js b/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/dashboard/terms-calendar-filter.js
new file mode 100644
index 0000000000..27bb60bc35
--- /dev/null
+++ b/packages/ilios-common/addon-test-support/ilios-common/page-objects/components/dashboard/terms-calendar-filter.js
@@ -0,0 +1,16 @@
+import { collection, clickable, create, property, text } from 'ember-cli-page-object';
+
+const definition = {
+ scope: '[data-test-vocabulary-filter]',
+ vocabularies: collection('[data-test-dashboard-selected-vocabulary]', {
+ title: text('[data-test-title]'),
+ terms: collection('[data-test-selected-term-tree]', {
+ title: text(),
+ toggle: clickable('[data-test-target]'),
+ isChecked: property('checked', '[data-test-target] input'),
+ }),
+ }),
+};
+
+export default definition;
+export const component = create(definition);
diff --git a/packages/ilios-common/addon/components/dashboard/calendar-filters.gjs b/packages/ilios-common/addon/components/dashboard/calendar-filters.gjs
index f2a91809ed..da9933c73e 100644
--- a/packages/ilios-common/addon/components/dashboard/calendar-filters.gjs
+++ b/packages/ilios-common/addon/components/dashboard/calendar-filters.gjs
@@ -9,8 +9,8 @@ import FilterCheckbox from 'ilios-common/components/dashboard/filter-checkbox';
import includes from 'ilios-common/helpers/includes';
import { fn } from '@ember/helper';
import FaIcon from 'ilios-common/components/fa-icon';
-import SelectedVocabulary from 'ilios-common/components/dashboard/selected-vocabulary';
import CohortCalendarFilter from 'ilios-common/components/dashboard/cohort-calendar-filter';
+import TermsCalendarFilter from 'ilios-common/components/dashboard/terms-calendar-filter';
export default class DashboardCalendarFiltersComponent extends Component {
@service dataLoader;
@@ -93,27 +93,12 @@ export default class DashboardCalendarFiltersComponent extends Component {
{{/if}}
-
-
- {{t "general.terms"}}
-
-
- {{#if this.vocabulariesLoaded}}
-
- {{#each this.vocabularies as |vocabulary|}}
-
- {{/each}}
-
- {{else}}
-
- {{/if}}
-
-
+
{{else}}
-
-
+
+
{{@vocabulary.title}}
{{#each (sortBy "title" this.topLevelTerms) as |term|}}
diff --git a/packages/ilios-common/addon/components/dashboard/terms-calendar-filter.gjs b/packages/ilios-common/addon/components/dashboard/terms-calendar-filter.gjs
new file mode 100644
index 0000000000..262b1ede88
--- /dev/null
+++ b/packages/ilios-common/addon/components/dashboard/terms-calendar-filter.gjs
@@ -0,0 +1,83 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { service } from '@ember/service';
+import t from 'ember-intl/helpers/t';
+import LoadingSpinner from 'ilios-common/components/loading-spinner';
+import SelectedVocabulary from 'ilios-common/components/dashboard/selected-vocabulary';
+
+export default class DashboardTermsCalendarFilterComponent extends Component {
+ @service dataLoader;
+ @service iliosConfig;
+
+ @tracked vocabulariesInView = [];
+ @tracked titlesInView = [];
+
+ @action
+ addVocabularyInView(vocabulary) {
+ if (!this.vocabulariesInView.includes(vocabulary)) {
+ this.vocabulariesInView = [...this.vocabulariesInView, vocabulary];
+ }
+ }
+ @action
+ removeVocabularyInView(vocabulary) {
+ this.vocabulariesInView = this.vocabulariesInView.filter(
+ (theVocabulary) => theVocabulary !== vocabulary,
+ );
+ }
+ @action
+ addTitleInView(title) {
+ if (!this.titlesInView.includes(title)) {
+ this.titlesInView = [...this.titlesInView, title];
+ }
+ }
+ @action
+ removeTitleInView(title) {
+ this.titlesInView = this.titlesInView.filter((theTitle) => theTitle !== title);
+ }
+
+ get vocabularyWithoutTitleView() {
+ const vocabulariesWithNoTitle = this.vocabulariesInView.filter(
+ (vocabulary) => !this.titlesInView.includes(vocabulary),
+ );
+ const expandedVocabulariesWithNoTitle = vocabulariesWithNoTitle.filter((vocabulary) =>
+ this.vocabulariesInView.includes(vocabulary),
+ );
+
+ if (expandedVocabulariesWithNoTitle.length) {
+ return expandedVocabulariesWithNoTitle[0];
+ }
+
+ return null;
+ }
+
+
+
+ {{t "general.terms"}}
+ {{#if this.vocabularyWithoutTitleView}}
+ ({{this.vocabularyWithoutTitleView}})
+ {{/if}}
+
+
+ {{#if @vocabularies}}
+
+ {{#each @vocabularies as |vocabulary|}}
+
+ {{/each}}
+
+ {{else}}
+
+ {{/if}}
+
+
+
+}
diff --git a/packages/ilios-common/app/components/dashboard/terms-calendar-filter.js b/packages/ilios-common/app/components/dashboard/terms-calendar-filter.js
new file mode 100644
index 0000000000..b962a69f51
--- /dev/null
+++ b/packages/ilios-common/app/components/dashboard/terms-calendar-filter.js
@@ -0,0 +1 @@
+export { default } from 'ilios-common/components/dashboard/terms-calendar-filter';
diff --git a/packages/test-app/tests/integration/components/dashboard/selected-vocabulary-test.gjs b/packages/test-app/tests/integration/components/dashboard/selected-vocabulary-test.gjs
index d3da01c5e4..8446c29550 100644
--- a/packages/test-app/tests/integration/components/dashboard/selected-vocabulary-test.gjs
+++ b/packages/test-app/tests/integration/components/dashboard/selected-vocabulary-test.gjs
@@ -41,6 +41,10 @@ module('Integration | Component | dashboard/selected-vocabulary', function (hook
@selectedTermIds={{this.selectedTermIds}}
@add={{(noop)}}
@remove={{(noop)}}
+ @addVocabularyInView={{(noop)}}
+ @removeVocabularyInView={{(noop)}}
+ @addTitleInView={{(noop)}}
+ @removeTitleInView={{(noop)}}
/>
,
);
@@ -68,6 +72,10 @@ module('Integration | Component | dashboard/selected-vocabulary', function (hook
@selectedTermIds={{(array)}}
@add={{this.add}}
@remove={{(noop)}}
+ @addVocabularyInView={{(noop)}}
+ @removeVocabularyInView={{(noop)}}
+ @addTitleInView={{(noop)}}
+ @removeTitleInView={{(noop)}}
/>
,
);
@@ -88,6 +96,10 @@ module('Integration | Component | dashboard/selected-vocabulary', function (hook
@selectedTermIds={{this.selectedTermIds}}
@add={{(noop)}}
@remove={{this.remove}}
+ @addVocabularyInView={{(noop)}}
+ @removeVocabularyInView={{(noop)}}
+ @addTitleInView={{(noop)}}
+ @removeTitleInView={{(noop)}}
/>
,
);
diff --git a/packages/test-app/tests/integration/components/dashboard/terms-calendar-filter-test.gjs b/packages/test-app/tests/integration/components/dashboard/terms-calendar-filter-test.gjs
new file mode 100644
index 0000000000..d5edf93f41
--- /dev/null
+++ b/packages/test-app/tests/integration/components/dashboard/terms-calendar-filter-test.gjs
@@ -0,0 +1,181 @@
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'test-app/tests/helpers';
+import { render } from '@ember/test-helpers';
+import { setupMirage } from 'test-app/tests/test-support/mirage';
+import { component } from 'ilios-common/page-objects/components/dashboard/terms-calendar-filter';
+import { a11yAudit } from 'ember-a11y-testing/test-support';
+import TermsCalendarFilter from 'ilios-common/components/dashboard/terms-calendar-filter';
+import noop from 'ilios-common/helpers/noop';
+import { array } from '@ember/helper';
+
+module('Integration | Component | dashboard/terms-calendar-filter', function (hooks) {
+ setupRenderingTest(hooks);
+ setupMirage(hooks);
+
+ hooks.beforeEach(async function () {
+ this.school = this.server.create('school');
+ this.vocab1 = this.server.create('vocabulary', {
+ school: this.school,
+ active: true,
+ });
+ const term1 = this.server.create('term', {
+ vocabulary: this.vocab1,
+ active: true,
+ });
+ const term2 = this.server.create('term', {
+ vocabulary: this.vocab1,
+ active: true,
+ });
+ this.vocab2 = this.server.create('vocabulary', {
+ school: this.school,
+ active: true,
+ });
+ const term3 = this.server.create('term', {
+ vocabulary: this.vocab2,
+ active: true,
+ });
+ const term4 = this.server.create('term', {
+ vocabulary: this.vocab2,
+ active: true,
+ });
+ this.server.create('course', {
+ year: 2024,
+ school: this.school,
+ terms: [term1, term2],
+ });
+ this.server.create('course', {
+ year: 2025,
+ school: this.school,
+ terms: [term3, term4],
+ });
+
+ this.vocabModel1 = await this.owner
+ .lookup('service:store')
+ .findRecord('vocabulary', this.vocab1.id);
+ this.vocabModel2 = await this.owner
+ .lookup('service:store')
+ .findRecord('vocabulary', this.vocab2.id);
+ });
+
+ test('it renders and is accessible', async function (assert) {
+ await render(
+
+
+ ,
+ );
+
+ assert.strictEqual(component.vocabularies.length, 2, 'vocabulary count is correct');
+ assert.strictEqual(
+ component.vocabularies[0].title,
+ 'Vocabulary 1',
+ 'first vocabulary title is correct',
+ );
+ assert.strictEqual(
+ component.vocabularies[1].title,
+ 'Vocabulary 2',
+ 'second vocabulary title is correct',
+ );
+
+ assert.strictEqual(
+ component.vocabularies[0].terms.length,
+ 2,
+ 'first vocabulary terms count is correct',
+ );
+ assert.strictEqual(
+ component.vocabularies[0].terms[0].title,
+ 'term 0',
+ 'first vocabulary, first term title is correct',
+ );
+ assert.strictEqual(
+ component.vocabularies[0].terms[1].title,
+ 'term 1',
+ 'first vocabulary, second term title is correct',
+ );
+
+ assert.strictEqual(
+ component.vocabularies[1].terms.length,
+ 2,
+ 'second vocabulary terms count is correct',
+ );
+ assert.strictEqual(
+ component.vocabularies[1].terms[0].title,
+ 'term 2',
+ 'second vocabulary, first term title is correct',
+ );
+ assert.strictEqual(
+ component.vocabularies[1].terms[1].title,
+ 'term 3',
+ 'second vocabulary, second term title is correct',
+ );
+
+ await a11yAudit(this.element);
+ assert.ok(true, 'no a11y errors found!');
+ });
+
+ test('selected terms are checked', async function (assert) {
+ await render(
+
+
+ ,
+ );
+ assert.strictEqual(component.vocabularies[0].terms.length, 2);
+
+ assert.strictEqual(component.vocabularies[0].terms[0].title, 'term 0');
+ assert.notOk(component.vocabularies[0].terms[0].isChecked);
+
+ assert.strictEqual(component.vocabularies[0].terms[1].title, 'term 1');
+ assert.ok(component.vocabularies[0].terms[1].isChecked);
+
+ assert.strictEqual(component.vocabularies[1].terms[0].title, 'term 2');
+ assert.ok(component.vocabularies[1].terms[0].isChecked);
+
+ assert.strictEqual(component.vocabularies[1].terms[1].title, 'term 3');
+ assert.notOk(component.vocabularies[1].terms[1].isChecked);
+ });
+
+ test('selected terms toggle remove', async function (assert) {
+ assert.expect(2);
+ this.set('remove', (id) => {
+ assert.strictEqual(id, '1');
+ });
+ await render(
+
+
+ ,
+ );
+ assert.ok(component.vocabularies[0].terms[0].isChecked);
+ await component.vocabularies[0].terms[0].toggle();
+ });
+
+ test('unselected terms toggle add', async function (assert) {
+ assert.expect(2);
+ this.set('add', (id) => {
+ assert.strictEqual(id, '1');
+ });
+ await render(
+
+
+ ,
+ );
+ assert.notOk(component.vocabularies[0].terms[0].isChecked);
+ await component.vocabularies[0].terms[0].toggle();
+ });
+});