@@ -151,4 +151,4 @@ {{/if}} - + \ No newline at end of file diff --git a/app/helpers/instance-of.ts b/app/helpers/instance-of.ts new file mode 100644 index 00000000000..c4aa98e2a86 --- /dev/null +++ b/app/helpers/instance-of.ts @@ -0,0 +1,20 @@ +import { getOwner } from '@ember/application'; +import { assert } from '@ember/debug'; + +import Helper from '@ember/component/helper'; + +export default class InstanceOf extends Helper { + compute([object, className]: [any, string]) { + if (!object || typeof className !== 'string') { + return false; + } + // Look up the class from the container + const owner = getOwner(this); + const klass = owner.factoryFor(`model:${className}`)?.class; + if (!klass) { + assert(`Class "${className}" not found`); + return false; + } + return object instanceof klass; + } +} diff --git a/app/institutions/dashboard/-components/chart-kpi-wrapper/chart-kpi/component-test.ts b/app/institutions/dashboard/-components/chart-kpi-wrapper/chart-kpi/component-test.ts new file mode 100644 index 00000000000..074e07d20c1 --- /dev/null +++ b/app/institutions/dashboard/-components/chart-kpi-wrapper/chart-kpi/component-test.ts @@ -0,0 +1,84 @@ +import { click, render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { setupIntl } from 'ember-intl/test-support'; +import { setupRenderingTest } from 'ember-qunit'; +import { TestContext } from 'ember-test-helpers'; +import { module, test } from 'qunit'; + +module('Integration | institutions | dashboard | -components | chart-kpi', hooks => { + setupRenderingTest(hooks); + setupMirage(hooks); + setupIntl(hooks); + + hooks.beforeEach(function(this: TestContext) { + const data = Object({ + title: 'This is the title', + chartData: [ + Object({ + label: 'a very long data set title that needs to be handled', + total: 100000, + }), + ], + chartType: 'pie', + }); + + this.set('data', data); + }); + + test('it renders the data correctly', async assert => { + + // Given the component is rendered + await render(hbs` + +`); + // Then the chart is verified + assert.dom('[data-test-chart]') + .exists('The test chart exists'); + + // And the title is verified + assert.dom('[data-test-chart-title]') + .hasText('This is the title'); + + assert.dom('[data-test-toggle-icon]') + .hasAttribute('data-icon', 'caret-down'); + + // Finally the expanded data is not visible + assert.dom('[data-test-expansion-data]') + .hasStyle({display: 'none'}); + }); + + test('it renders the expanded data correctly', async assert => { + + // Given the component is rendered + await render(hbs` + +`); + // When I click the expanded icon + await click('[data-test-expand-additional-data]'); + + // Then I verify the icon has changed + assert.dom('[data-test-toggle-icon]') + .hasAttribute('data-icon', 'caret-up'); + + // And the expanded data is visible + assert.dom('[data-test-expansion-data]') + .exists('The expansion data is visible'); + + // And the expanded data position 0 color is verified + assert.dom('[data-test-expanded-color="0"]') + .hasAttribute('style', 'background-color:#00D1FF'); + + // And the expanded data position 0 name is verified + assert.dom('[data-test-expanded-name="0"]') + .hasText('a very long data set title that needs to be handled'); + + // And the expanded data position 0 total is verified + assert.dom('[data-test-expanded-total="0"]') + .hasText('100000'); + }); +}); diff --git a/app/institutions/dashboard/-components/chart-kpi-wrapper/chart-kpi/component.ts b/app/institutions/dashboard/-components/chart-kpi-wrapper/chart-kpi/component.ts new file mode 100644 index 00000000000..252b2978d98 --- /dev/null +++ b/app/institutions/dashboard/-components/chart-kpi-wrapper/chart-kpi/component.ts @@ -0,0 +1,118 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { ChartData, ChartOptions } from 'ember-cli-chart'; +import Intl from 'ember-intl/services/intl'; +// eslint-disable-next-line max-len +import { ChartDataModel, KpiChartModel } from 'ember-osf-web/institutions/dashboard/-components/chart-kpi-wrapper/component'; + +interface KPIChartWrapperArgs { + data: KpiChartModel; +} + +interface DataModel { + name: string; + total: number; + color: string; +} + +export default class ChartKpi extends Component { + @service intl!: Intl; + + @tracked collapsed = true; + @tracked expandedData = [] as DataModel[]; + + /** + * chartOptions + * + * @description A getter for the chartjs options + * + * @returns a ChartOptions model which is custom to COS + */ + get chartOptions(): ChartOptions { + const options = { + aspectRatio: 1, + legend: { + display: false, + }, + scales: { + xAxes: [{ + display: false, + }], + yAxes: [{ + display: false, + ticks: { min: 0 }, + }], + }, + }; + if (this.args.data.chartType === 'bar') { + options.scales.yAxes[0].display = true; + } + return options; + } + + /** + * getColor + * + * @description Gets a specific color using a modulus + * + * @param index The index to retrieve + * + * @returns the color + */ + private getColor(index: number): string { + const backgroundColors = [ + '#00D1FF', + '#009CEF', + '#0063EF', + '#00568D', + '#004673', + '#00375A', + '#263947', + ]; + + return backgroundColors[index % backgroundColors.length]; + } + + /** + * chartData + * + * @description Transforms the standard chart data into data the charts can display + * + * @returns void + */ + get chartData(): ChartData { + const backgroundColors = [] as string[]; + const data = [] as number[]; + const labels = [] as string[]; + const { taskInstance, chartData } = this.args.data; + + const rawData = taskInstance?.value || chartData || []; + + rawData.forEach((rawChartData: ChartDataModel, $index: number) => { + backgroundColors.push(this.getColor($index)); + + data.push(rawChartData.total); + labels.push(rawChartData.label); + this.expandedData.push({ + name: rawChartData.label, + total: rawChartData.total, + color: this.getColor($index), + }); + }); + return { + labels, + datasets: [{ + data, + fill: false, + backgroundColor: backgroundColors, + }], + }; + } + + @action + public toggleExpandedData() { + this.collapsed = !this.collapsed; + } +} diff --git a/app/institutions/dashboard/-components/chart-kpi-wrapper/chart-kpi/styles.scss b/app/institutions/dashboard/-components/chart-kpi-wrapper/chart-kpi/styles.scss new file mode 100644 index 00000000000..fb6757a930b --- /dev/null +++ b/app/institutions/dashboard/-components/chart-kpi-wrapper/chart-kpi/styles.scss @@ -0,0 +1,116 @@ +// stylelint-disable max-nesting-depth, selector-max-compound-selectors + +.chart-container { + margin-right: 12px; + margin-bottom: 12px; + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; + align-items: flex-start; + width: 350px; + min-height: 290px; + height: fit-content; + background-color: $color-bg-white; + + .top-container { + width: 100%; + height: 240px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + .ember-chart { + max-width: 220px; + max-height: 220px; + } + } + + .bottom-container { + width: 100%; + min-height: 50px; + height: fit-content; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + + .title-container { + width: 100%; + height: 50px; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + + .title { + font-size: 14px; + font-weight: normal; + height: 25px; + } + + .button-container { + margin-left: 5px; + height: 25px; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + } + } + + .expanded-data-container { + width: 100%; + padding-top: 10px; + display: flex; + justify-content: center; + align-items: flex-start; + + &.collapsed { + display: none; + } + + .data-list { + list-style: none; + margin: 0; + padding: 0.2rem; + width: calc(100% - 0.2rem); + border-top: 2px solid $color-bg-gray; + + .data-container { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + width: 282px; + + .name { + margin: 0 5px; + width: calc(282px - 100px); + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + + .color { + width: 20px; + height: 20px; + } + + .total { + width: 80px; + text-align: right; + } + } + } + } + } + + &.mobile { + margin-right: 0; + margin-bottom: 12px; + } +} + + diff --git a/app/institutions/dashboard/-components/chart-kpi-wrapper/chart-kpi/template.hbs b/app/institutions/dashboard/-components/chart-kpi-wrapper/chart-kpi/template.hbs new file mode 100644 index 00000000000..d55b0fd48ee --- /dev/null +++ b/app/institutions/dashboard/-components/chart-kpi-wrapper/chart-kpi/template.hbs @@ -0,0 +1,75 @@ +
+ {{#if @data.taskInstance.isRunning}} + + {{else if @data.taskInstance.isError}} + {{t 'institutions.dashboard.kpi-chart.error'}} + {{else}} +
+
+ +
+
+
+ {{#let (unique-id 'expanded-data') as |expandedDataId|}} +
+
{{@data.title}}
+
+ +
+
+
+
    + {{#each this.expandedData as |data index |}} +
  • +
    +
    +
    + {{data.name}} +
    +
    + {{data.total}} +
    +
  • + {{/each}} +
+
+ {{/let}} +
+ {{/if}} +
\ No newline at end of file diff --git a/app/institutions/dashboard/-components/chart-kpi-wrapper/component-test.ts b/app/institutions/dashboard/-components/chart-kpi-wrapper/component-test.ts new file mode 100644 index 00000000000..c72272b1d6a --- /dev/null +++ b/app/institutions/dashboard/-components/chart-kpi-wrapper/component-test.ts @@ -0,0 +1,333 @@ +import { click, render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { setupIntl } from 'ember-intl/test-support'; +import { setupRenderingTest } from 'ember-qunit'; +import { TestContext } from 'ember-test-helpers'; +import { module, test } from 'qunit'; + +module('Integration | institutions | dashboard | -components | kpi-chart-wrapper', hooks => { + setupRenderingTest(hooks); + setupMirage(hooks); + setupIntl(hooks); + + hooks.beforeEach(function(this: TestContext) { + const model = Object({ + summaryMetrics: { + userCount: 10, + privateProjectCount: 15, + publicProjectCount: 20, + publicRegistrationCount: 100, + embargoedRegistrationCount: 200, + publishedPreprintCount: 1000, + storageByteCount: 2000, + }, + departmentMetrics: [ + { + name: 'Math', + numberOfUsers: 25, + }, + { + name: 'Science', + numberOfUsers: 37, + }, + ], + institution: { + iris: ['bleh'], + }, + }); + + this.set('model', model); + }); + + test('it calculates the Total Users by Department data correctly', async function(assert) { + // Given the component is rendered + await render(hbs` + +`); + const parentDom = '[data-test-kpi-chart="0"]'; + // When I click the expanded icon + await click(`${parentDom} [data-test-expand-additional-data]`); + + // And the title is verified + assert.dom(`${parentDom} [data-test-chart-title]`) + .hasText('Total Users by Department'); + + // And the expanded data position 0 name is verified + assert.dom(`${parentDom} [data-test-expanded-name="0"]`) + .hasText('Math'); + + // And the expanded data position 0 total is verified + assert.dom(`${parentDom} [data-test-expanded-total="0"]`) + .hasText('25'); + + // And the expanded data position 1 name is verified + assert.dom(`${parentDom} [data-test-expanded-name="1"]`) + .hasText('Science'); + + // And the expanded data position 1 total is verified + assert.dom(`${parentDom} [data-test-expanded-total="1"]`) + .hasText('37'); + + // Finally there are only 2 expanded data points + assert.dom(`${parentDom} [data-test-expanded-name="2"]`) + .doesNotExist(); + }); + + test('it calculates the Public vs Private Project data correctly', async function(assert) { + // Given the component is rendered + await render(hbs` + +`); + const parentDom = '[data-test-kpi-chart="1"]'; + + // When I click the expanded icon + await click(`${parentDom} [data-test-expand-additional-data]`); + + // And the title is verified + assert.dom(`${parentDom} [data-test-chart-title]`) + .hasText('Public vs Private Projects'); + + // And the expanded data position 0 name is verified + assert.dom(`${parentDom} [data-test-expanded-name="0"]`) + .hasText('Public Projects'); + + // And the expanded data position 0 total is verified + assert.dom(`${parentDom} [data-test-expanded-total="0"]`) + .hasText('20'); + + // And the expanded data position 1 name is verified + assert.dom(`${parentDom} [data-test-expanded-name="1"]`) + .hasText('Private Projects'); + + // And the expanded data position 1 total is verified + assert.dom(`${parentDom} [data-test-expanded-total="1"]`) + .hasText('15'); + + // Finally there are only 2 expanded data points + assert.dom(`${parentDom} [data-test-expanded-name="2"]`) + .doesNotExist(); + }); + + test('it calculates the Public vs Embargoed Registration data correctly', async function(assert) { + // Given the component is rendered + await render(hbs` + +`); + const parentDom = '[data-test-kpi-chart="2"]'; + + // When I click the expanded icon + await click(`${parentDom} [data-test-expand-additional-data]`); + + // And the title is verified + assert.dom(`${parentDom} [data-test-chart-title]`) + .hasText('Public vs Embargoed Registrations'); + + // And the expanded data position 0 name is verified + assert.dom(`${parentDom} [data-test-expanded-name="0"]`) + .hasText('Public Registrations'); + + // And the expanded data position 0 total is verified + assert.dom(`${parentDom} [data-test-expanded-total="0"]`) + .hasText('100'); + + // And the expanded data position 1 name is verified + assert.dom(`${parentDom} [data-test-expanded-name="1"]`) + .hasText('Embargoed Registrations'); + + // And the expanded data position 1 total is verified + assert.dom(`${parentDom} [data-test-expanded-total="1"]`) + .hasText('200'); + + // Finally there are only 2 expanded data points + assert.dom(`${parentDom} [data-test-expanded-name="2"]`) + .doesNotExist(); + }); + + test('it calculates the Total Objects data correctly', async function(assert) { + // Given the component is rendered + await render(hbs` + +`); + const parentDom = '[data-test-kpi-chart="3"]'; + + // When I click the expanded icon + await click(`${parentDom} [data-test-expand-additional-data]`); + + // And the title is verified + assert.dom(`${parentDom} [data-test-chart-title]`) + .hasText('Total OSF Objects'); + + // And the expanded data position 0 name is verified + assert.dom(`${parentDom} [data-test-expanded-name="0"]`) + .hasText('Preprints'); + + // And the expanded data position 0 total is verified + assert.dom(`${parentDom} [data-test-expanded-total="0"]`) + .hasText('1000'); + + // And the expanded data position 1 name is verified + assert.dom(`${parentDom} [data-test-expanded-name="1"]`) + .hasText('Public Projects'); + + // And the expanded data position 1 total is verified + assert.dom(`${parentDom} [data-test-expanded-total="1"]`) + .hasText('20'); + + // And the expanded data position 2 name is verified + assert.dom(`${parentDom} [data-test-expanded-name="2"]`) + .hasText('Private Projects'); + + // And the expanded data position 2 total is verified + assert.dom(`${parentDom} [data-test-expanded-total="2"]`) + .hasText('15'); + + // And the expanded data position 3 name is verified + assert.dom(`${parentDom} [data-test-expanded-name="3"]`) + .hasText('Public Registrations'); + + // And the expanded data position 3 total is verified + assert.dom(`${parentDom} [data-test-expanded-total="3"]`) + .hasText('100'); + + // And the expanded data position 4 name is verified + assert.dom(`${parentDom} [data-test-expanded-name="4"]`) + .hasText('Embargoed Registrations'); + + // And the expanded data position 4 total is verified + assert.dom(`${parentDom} [data-test-expanded-total="4"]`) + .hasText('200'); + + // Finally there are only 5 expanded data points + assert.dom(`${parentDom} [data-test-expanded-name="5"]`) + .doesNotExist(); + }); + + test('it calculates the Licenses data correctly', async function(assert) { + // Given the component is rendered + await render(hbs` + +`); + const parentDom = '[data-test-kpi-chart="4"]'; + // When I click the expanded icon + await click(`${parentDom} [data-test-expand-additional-data]`); + + // And the title is verified + assert.dom(`${parentDom} [data-test-chart-title]`) + .hasText('Top 10 Licenses'); + + // And the expanded data position 0 name is verified + assert.dom(`${parentDom} [data-test-expanded-name="0"]`) + .exists(); + + // And the expanded data position 0 total is verified + assert.dom(`${parentDom} [data-test-expanded-total="0"]`) + .hasText('3'); + + // And the expanded data position 1 name is verified + assert.dom(`${parentDom} [data-test-expanded-name="1"]`) + .exists(); + + // And the expanded data position 1 total is verified + assert.dom(`${parentDom} [data-test-expanded-total="1"]`) + .hasText('2'); + + assert.dom(`${parentDom} [data-test-expanded-total="2"]`) + .doesNotExist(); + }); + + test('it calculates the Addon data correctly', async function(assert) { + // Given the component is rendered + await render(hbs` + +`); + const parentDom = '[data-test-kpi-chart="5"]'; + // When I click the expanded icon + await click(`${parentDom} [data-test-expand-additional-data]`); + + // And the title is verified + assert.dom(`${parentDom} [data-test-chart-title]`) + .hasText('Top 10 Add-ons'); + + // And the expanded data position 0 name is verified + assert.dom(`${parentDom} [data-test-expanded-name="0"]`) + .exists(); + + // And the expanded data position 0 total is verified + assert.dom(`${parentDom} [data-test-expanded-total="0"]`) + .hasText('3'); + + // And the expanded data position 1 name is verified + assert.dom(`${parentDom} [data-test-expanded-name="1"]`) + .exists(); + + // And the expanded data position 1 total is verified + assert.dom(`${parentDom} [data-test-expanded-total="1"]`) + .hasText('2'); + + // Finally there are only 2 expanded data points + assert.dom(`${parentDom} [data-test-expanded-name="2"]`) + .doesNotExist(); + }); + + test('it calculates the Storage Regions data correctly', async function(assert) { + // Given the component is rendered + await render(hbs` + +`); + const parentDom = '[data-test-kpi-chart="6"]'; + // When I click the expanded icon + await click(`${parentDom} [data-test-expand-additional-data]`); + + // And the title is verified + assert.dom(`${parentDom} [data-test-chart-title]`) + .hasText('Top Storage Regions'); + + // And the expanded data position 0 name is verified + assert.dom(`${parentDom} [data-test-expanded-name="0"]`) + .exists(); + + // And the expanded data position 0 total is verified + assert.dom(`${parentDom} [data-test-expanded-total="0"]`) + .hasText('3'); + + // And the expanded data position 1 name is verified + assert.dom(`${parentDom} [data-test-expanded-name="1"]`) + .exists(); + + // And the expanded data position 1 total is verified + assert.dom(`${parentDom} [data-test-expanded-total="1"]`) + .hasText('2'); + + // Finally there are only 2 expanded data points + assert.dom(`${parentDom} [data-test-expanded-name="2"]`) + .doesNotExist(); + }); + + test('it renders the dashboard total charts correctly', async assert => { + // Given the component is rendered + await render(hbs` + +`); + + // Then there are only 8 charts + assert.dom('[data-test-kpi-chart="8"]') + .doesNotExist('There are only 8 charts'); + }); +}); diff --git a/app/institutions/dashboard/-components/chart-kpi-wrapper/component.ts b/app/institutions/dashboard/-components/chart-kpi-wrapper/component.ts new file mode 100644 index 00000000000..c5c2c039dee --- /dev/null +++ b/app/institutions/dashboard/-components/chart-kpi-wrapper/component.ts @@ -0,0 +1,220 @@ +import Store from '@ember-data/store'; +import { inject as service } from '@ember/service'; +import { waitFor } from '@ember/test-waiters'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { task, TaskInstance } from 'ember-concurrency'; +import { taskFor } from 'ember-concurrency-ts'; +import Intl from 'ember-intl/services/intl'; + +import InstitutionDepartmentModel from 'ember-osf-web/models/institution-department'; +import InstitutionSummaryMetricModel from 'ember-osf-web/models/institution-summary-metric'; +import SearchResultModel from 'ember-osf-web/models/search-result'; + +export interface ChartDataModel { + label: string; + total: number; +} + +interface TotalCountChartWrapperArgs { + model: any; +} + +export interface KpiChartModel { + title: string; + chartType: string; + // Either chartData or taskInstance should be defined + chartData?: ChartDataModel[]; + taskInstance?: TaskInstance; +} + +export default class ChartKpiWrapperComponent extends Component { + @service intl!: Intl; + @service store!: Store; + + @tracked model = this.args.model; + @tracked kpiCharts = [] as KpiChartModel[]; + @tracked isLoading = true; + + constructor(owner: unknown, args: TotalCountChartWrapperArgs) { + super(owner, args); + + taskFor(this.loadData).perform(); + } + + /** + * loadData + * + * @description Loads all the data and builds the chart data before rendering the page + * + * @returns a void Promise + */ + @task + @waitFor + private async loadData(): Promise { + const metrics = await this.model; + + const getLicenseTask = taskFor(this.getShareData).perform('rights'); + const getAddonsTask = taskFor(this.getShareData).perform('hasOsfAddon'); + const getRegionTask = taskFor(this.getShareData) + .perform('storageRegion'); + + this.kpiCharts.push( + { + title: this.intl.t('institutions.dashboard.kpi-chart.users-by-department'), + chartData: this.calculateUsersByDepartment(metrics.departmentMetrics), + chartType: 'doughnut', + }, + { + title: this.intl.t('institutions.dashboard.kpi-chart.public-vs-private-projects.title'), + chartData: this.calculateProjects(metrics.summaryMetrics), + chartType: 'doughnut', + }, + { + title: this.intl.t('institutions.dashboard.kpi-chart.public-vs-embargoed-registrations.title'), + chartData: this.calculateRegistrations(metrics.summaryMetrics), + chartType: 'doughnut', + }, + { + title: this.intl.t('institutions.dashboard.kpi-chart.total-osf-objects.title'), + chartData: this.calculateOSFObjects(metrics.summaryMetrics), + chartType: 'doughnut', + }, + { + title: this.intl.t('institutions.dashboard.kpi-chart.licenses'), + chartType: 'bar', + taskInstance: getLicenseTask, + }, + { + title: this.intl.t('institutions.dashboard.kpi-chart.add-ons'), + chartType: 'bar', + taskInstance: getAddonsTask, + }, + { + title: this.intl.t('institutions.dashboard.kpi-chart.storage-regions'), + chartType: 'doughnut', + taskInstance: getRegionTask, + }, + ); + + this.isLoading = false; + } + + /** + * calculateUserByDepartments + * + * @description Abstracted method to build the ChartData model for departments + * @param departmentMetrics The department metrics object + * + * @returns The users by department ChartData model + */ + private calculateUsersByDepartment(departmentMetrics: InstitutionDepartmentModel[]): ChartDataModel[] { + const departmentData = [] as ChartDataModel[]; + + departmentMetrics.forEach((metric: InstitutionDepartmentModel ) => { + departmentData.push( + { + label: metric.name, + total: metric.numberOfUsers, + } as ChartDataModel, + ); + }); + return departmentData; + } + + /** + * calculateRegistrations + * + * @description Abstracted method to calculate the private and public registrations + * @param summaryMetrics The institutional summary metrics object + * + * @returns The total of private and public registrations + */ + private calculateRegistrations(summaryMetrics: InstitutionSummaryMetricModel): ChartDataModel[] { + return [ + { + label: this.intl.t('institutions.dashboard.kpi-chart.public-vs-embargoed-registrations.public'), + total: summaryMetrics.publicRegistrationCount, + } as ChartDataModel, + { + label: this.intl.t('institutions.dashboard.kpi-chart.public-vs-embargoed-registrations.embargoed'), + total: summaryMetrics.embargoedRegistrationCount, + } as ChartDataModel, + ]; + } + + /** + * calculateOSFObjects + * + * @description Abstracted method to calculate the osf objects + * + * @param summaryMetrics The institutional summary metrics object + * + * @returns The total OSF objects + */ + private calculateOSFObjects(summaryMetrics: InstitutionSummaryMetricModel): ChartDataModel[] { + let chartData = [ + { + label: this.intl.t('institutions.dashboard.kpi-chart.total-osf-objects.preprints'), + total: summaryMetrics.publishedPreprintCount, + } as ChartDataModel, + ]; + + chartData = chartData.concat(this.calculateProjects(summaryMetrics)); + + chartData = chartData.concat(this.calculateRegistrations(summaryMetrics)); + + return chartData; + } + + /** + * calculateProjects + * + * @description Abstracted method to calculate the private and public projects + * @param summaryMetrics The institutional summary metrics object + * + * @returns The total of private and public projects + */ + private calculateProjects(summaryMetrics: InstitutionSummaryMetricModel): ChartDataModel[] { + return [ + { + label: this.intl.t('institutions.dashboard.kpi-chart.public-vs-private-projects.public'), + total: summaryMetrics.publicProjectCount, + } as ChartDataModel, + { + label: this.intl.t('institutions.dashboard.kpi-chart.public-vs-private-projects.private'), + total: summaryMetrics.privateProjectCount, + } as ChartDataModel, + ]; + } + + /** + * getShareData + * + * @description Abstracted task to fetch data associated with the institution from SHARE + * @param propertyPath The property path to search for + * (e.g. propertyPathKey in the `related-property-path` of an index-card-search) + * + * @returns ChartDataModel[] The labels and totals for each section + * + */ + @task + @waitFor + private async getShareData( + propertyPath: string, + ): Promise { + const valueSearch = await this.store.queryRecord('index-value-search', { + cardSearchFilter: { + affiliation: this.args.model.institution.iris.join(','), + }, + 'page[size]': 10, + valueSearchPropertyPath: propertyPath, + }); + const resultPage = valueSearch.searchResultPage.toArray(); + + return resultPage.map((result: SearchResultModel) => ({ + total: result.cardSearchResultCount, + label: result.indexCard.get('label'), + })); + } +} diff --git a/app/institutions/dashboard/-components/chart-kpi-wrapper/styles.scss b/app/institutions/dashboard/-components/chart-kpi-wrapper/styles.scss new file mode 100644 index 00000000000..240c5812664 --- /dev/null +++ b/app/institutions/dashboard/-components/chart-kpi-wrapper/styles.scss @@ -0,0 +1,29 @@ +.wrapper-container { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + align-items: flex-start; + width: calc(100% - 24px); + min-height: 290px; + height: fit-content; + margin-left: 12px; + margin-right: 12px; + margin-bottom: 12px; + + .loading { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + width: 100%; + height: 290px; + } + + &.mobile { + flex-direction: column; + height: fit-content; + align-items: center; + margin-bottom: 0; + } +} diff --git a/app/institutions/dashboard/-components/chart-kpi-wrapper/template.hbs b/app/institutions/dashboard/-components/chart-kpi-wrapper/template.hbs new file mode 100644 index 00000000000..7dad4cbed7d --- /dev/null +++ b/app/institutions/dashboard/-components/chart-kpi-wrapper/template.hbs @@ -0,0 +1,14 @@ +
+ {{#if this.isLoading}} +
+ +
+ {{else}} + {{#each this.kpiCharts as |kpiChart index|}} + + {{/each}} + {{/if}} +
\ No newline at end of file diff --git a/app/institutions/dashboard/-components/departments-panel/component.ts b/app/institutions/dashboard/-components/departments-panel/component.ts deleted file mode 100644 index b40dd29b687..00000000000 --- a/app/institutions/dashboard/-components/departments-panel/component.ts +++ /dev/null @@ -1,75 +0,0 @@ -import Component from '@ember/component'; -import { action, computed } from '@ember/object'; -import { inject as service } from '@ember/service'; -import { ChartData, ChartOptions, Shape } from 'ember-cli-chart'; -import Intl from 'ember-intl/services/intl'; -import { Department } from 'ember-osf-web/models/institution'; -import InstitutionDepartmentsModel from 'ember-osf-web/models/institution-department'; - -export default class DepartmentsPanel extends Component { - @service intl!: Intl; - - topDepartments!: InstitutionDepartmentsModel[]; - totalUsers!: number; - - chartHoverIndex = 0; - - get chartOptions(): ChartOptions { - return { - aspectRatio: 1, - legend: { - display: false, - }, - onHover: this.onChartHover, - }; - } - - @action - onChartHover(_: MouseEvent, shapes: Shape[]) { - if (shapes.length === 0 || this.chartHoverIndex === shapes[0]._index) { - return; - } - this.set('chartHoverIndex', shapes[0]._index); - } - - @computed('topDepartments', 'totalUsers') - get displayDepartments() { - const departments = this.topDepartments.map(({ name, numberOfUsers }) => ({ name, numberOfUsers })); - const departmentNumbers = this.topDepartments.map(x => x.numberOfUsers); - const otherDepartmentNumber = this.totalUsers - departmentNumbers.reduce((a, b) => a + b); - - return [...departments, { name: this.intl.t('general.other'), numberOfUsers: otherDepartmentNumber }]; - } - - @computed('chartHoverIndex', 'displayDepartments.[]') - get chartData(): ChartData { - const backgroundColors = this.displayDepartments.map((_, i) => { - if (i === this.chartHoverIndex) { - return '#15a5eb'; - } - return '#a5b3bd'; - }); - const displayDepartmentNames = this.displayDepartments.map(({ name }) => name); - const displayDepartmentNumbers = this.displayDepartments.map(({ numberOfUsers }) => numberOfUsers); - - return { - labels: displayDepartmentNames, - datasets: [{ - data: displayDepartmentNumbers, - backgroundColor: backgroundColors, - }], - }; - } - - @computed('chartHoverIndex', 'displayDepartments.[]') - get activeDepartment(): Department { - return this.displayDepartments[this.chartHoverIndex]; - } - - @computed('activeDepartment.numberOfUsers', 'displayDepartments') - get activeDepartmentPercentage(): string { - const numUsersArray = this.displayDepartments.map(({ numberOfUsers }) => numberOfUsers); - const count = numUsersArray.reduce((a, b) => a + b); - return ((this.activeDepartment.numberOfUsers / count) * 100).toFixed(2); - } -} diff --git a/app/institutions/dashboard/-components/departments-panel/styles.scss b/app/institutions/dashboard/-components/departments-panel/styles.scss deleted file mode 100644 index 6d16df12aa5..00000000000 --- a/app/institutions/dashboard/-components/departments-panel/styles.scss +++ /dev/null @@ -1,16 +0,0 @@ -.ember-chart { - max-width: 200px; - max-height: 200px; - margin: 0 auto 15px; -} - -.department { - font-size: 16px; - - h3 { - margin: 0 0 10px; - font-size: 24px; - font-weight: bold; - } -} - diff --git a/app/institutions/dashboard/-components/departments-panel/template.hbs b/app/institutions/dashboard/-components/departments-panel/template.hbs deleted file mode 100644 index b07bd993c49..00000000000 --- a/app/institutions/dashboard/-components/departments-panel/template.hbs +++ /dev/null @@ -1,20 +0,0 @@ -{{#if this.topDepartments}} -
- -
-
-

{{this.activeDepartment.name}}

-

- {{this.activeDepartmentPercentage}}%: {{this.activeDepartment.numberOfUsers}} {{t 'institutions.dashboard.users'}} -

-
-{{else}} - {{t 'institutions.dashboard.empty'}} -{{/if}} \ No newline at end of file diff --git a/app/institutions/dashboard/-components/institutional-dashboard-wrapper/styles.scss b/app/institutions/dashboard/-components/institutional-dashboard-wrapper/styles.scss new file mode 100644 index 00000000000..4e024d79963 --- /dev/null +++ b/app/institutions/dashboard/-components/institutional-dashboard-wrapper/styles.scss @@ -0,0 +1,54 @@ +@import 'app/styles/layout'; +@import 'app/styles/components'; + +.container { + > div { // override OsfLayout styles for forcing drawer mode + overflow-x: hidden; + } +} + +.heading-wrapper { + border-bottom: 1px solid #ddd; +} + +.banner { + @include clamp-width; + padding: 15px 0; + display: flex; + align-items: center; + justify-content: space-between; +} + +.institution-banner { + max-width: 100%; + max-height: 300px; +} + +.tab-list { + @include clamp-width; + @include tab-list; + white-space: nowrap; + display: flex; + flex-wrap: nowrap; + position: relative; + overflow-x: auto; + margin-bottom: 0; + border-bottom: 0; + + li { + display: inline-flex; + padding: 5px 10px; + + &:has(a:global(.active)) { + background-color: $bg-light; + border-bottom: 2px solid $color-blue; + } + + &:has(a:hover) { + border-color: transparent; + text-decoration: none; + background-color: $bg-light; + color: var(--primary-color); + } + } +} diff --git a/app/institutions/dashboard/-components/institutional-dashboard-wrapper/template.hbs b/app/institutions/dashboard/-components/institutional-dashboard-wrapper/template.hbs new file mode 100644 index 00000000000..1815421af6d --- /dev/null +++ b/app/institutions/dashboard/-components/institutional-dashboard-wrapper/template.hbs @@ -0,0 +1,85 @@ + + +
+ {{@institution.name}} +
+
    +
  • + + {{t 'institutions.dashboard.tabs.summary'}} + +
  • +
  • + + {{t 'institutions.dashboard.tabs.users'}} + +
  • +
  • + + {{t 'institutions.dashboard.tabs.projects'}} + +
  • +
  • + + {{t 'institutions.dashboard.tabs.registrations'}} + +
  • +
  • + + {{t 'institutions.dashboard.tabs.preprints'}} + +
  • +
+
+ + {{yield (hash + left=layout.left + right=layout.right + top=layout.top + main=layout.main + )}} +
diff --git a/app/institutions/dashboard/-components/institutional-users-list/component.ts b/app/institutions/dashboard/-components/institutional-users-list/component.ts index 992d612306f..fad0e1033b7 100644 --- a/app/institutions/dashboard/-components/institutional-users-list/component.ts +++ b/app/institutions/dashboard/-components/institutional-users-list/component.ts @@ -1,72 +1,230 @@ -import Component from '@ember/component'; -import { action, computed } from '@ember/object'; -import { reads } from '@ember/object/computed'; +import { task } from 'ember-concurrency'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; import { inject as service } from '@ember/service'; import { waitFor } from '@ember/test-waiters'; -import { restartableTask, TaskInstance, timeout } from 'ember-concurrency'; +import { restartableTask, timeout } from 'ember-concurrency'; import Intl from 'ember-intl/services/intl'; -import { InstitutionsDashboardModel } from 'ember-osf-web/institutions/dashboard/route'; import InstitutionModel from 'ember-osf-web/models/institution'; import InstitutionDepartmentsModel from 'ember-osf-web/models/institution-department'; import Analytics from 'ember-osf-web/services/analytics'; +import { RelationshipWithLinks } from 'osf-api'; +import { MessageTypeChoices } from 'ember-osf-web/models/user-message'; -export default class InstitutionalUsersList extends Component { +interface Column { + key: string; + selected: boolean; + label: string; + sort_key: string | false; + type: 'string' | 'date_by_month' | 'osf_link' | 'user_name' | 'orcid'; +} + +interface InstitutionalUsersListArgs { + institution: InstitutionModel; + departmentMetrics: InstitutionDepartmentsModel[]; +} + +export default class InstitutionalUsersList extends Component { @service analytics!: Analytics; @service intl!: Intl; + @service store; + @service currentUser!: CurrentUser; - @reads('modelTaskInstance.value.institution') institution?: InstitutionModel; - @reads('modelTaskInstance.value.departmentMetrics') departmentMetrics?: InstitutionDepartmentsModel[]; + // Properties + @tracked department = this.defaultDepartment; + @tracked sort = 'user_name'; + @tracked selectedDepartments: string[] = []; + @tracked filteredUsers = []; + @tracked messageModalShown = false; + @tracked messageText = ''; + @tracked bccSender = false; + @tracked replyTo = false; + @tracked selectedUserId = null; + @service toast!: Toast; + + @tracked columns: Column[] = [ + { + key: 'user_name', + sort_key: 'user_name', + label: this.intl.t('institutions.dashboard.users_list.name'), + selected: true, + type: 'user_name', + }, + { + key: 'department', + sort_key: 'department', + label: this.intl.t('institutions.dashboard.users_list.department'), + selected: true, + type: 'string', + }, + { + key: 'osf_link', + sort_key: false, + label: this.intl.t('institutions.dashboard.users_list.osf_link'), + selected: true, + type: 'osf_link', + }, + { + key: 'orcid', + sort_key: false, + label: this.intl.t('institutions.dashboard.users_list.orcid'), + selected: true, + type: 'orcid', + }, + { + key: 'publicProjects', + sort_key: 'public_projects', + label: this.intl.t('institutions.dashboard.users_list.public_projects'), + selected: true, + type: 'string', + }, + { + key: 'privateProjects', + sort_key: 'private_projects', + label: this.intl.t('institutions.dashboard.users_list.private_projects'), + selected: true, + type: 'string', + }, + { + key: 'publicRegistrationCount', + sort_key: 'public_registration_count', + label: this.intl.t('institutions.dashboard.users_list.public_registration_count'), + selected: true, + type: 'string', + }, + { + key: 'embargoedRegistrationCount', + sort_key: 'embargoed_registration_count', + label: this.intl.t('institutions.dashboard.users_list.embargoed_registration_count'), + selected: true, + type: 'string', + }, + { + key: 'publishedPreprintCount', + sort_key: 'published_preprint_count', + label: this.intl.t('institutions.dashboard.users_list.published_preprint_count'), + selected: true, + type: 'string', + }, + { + key: 'publicFileCount', + sort_key: 'public_file_count', + label: this.intl.t('institutions.dashboard.users_list.public_file_count'), + selected: false, + type: 'string', + }, + { + key: 'userDataUsage', + sort_key: 'storage_byte_count', + label: this.intl.t('institutions.dashboard.users_list.storage_byte_count'), + selected: false, + type: 'string', + }, + { + key: 'accountCreationDate', + sort_key: 'account_creation_date', + label: this.intl.t('institutions.dashboard.users_list.account_created'), + selected: false, + type: 'date_by_month', + }, + { + key: 'monthLastLogin', + sort_key: 'month_last_login', + label: this.intl.t('institutions.dashboard.users_list.month_last_login'), + selected: false, + type: 'date_by_month', + }, + { + key: 'monthLastActive', + sort_key: 'month_last_active', + label: this.intl.t('institutions.dashboard.users_list.month_last_active'), + selected: false, + type: 'date_by_month', + }, + ]; + + @tracked selectedColumns: string[] = this.columns.filter(col => col.selected).map(col => col.key); // Private properties - modelTaskInstance!: TaskInstance; - department = this.intl.t('institutions.dashboard.select_default'); - sort = 'user_name'; + @tracked hasOrcid = false; + @tracked totalUsers = 0; + orcidUrlPrefix = 'https://orcid.org/'; - reloadUserList?: () => void; + @action + toggleColumnSelection(columnKey: string) { + const column = this.columns.find(col => col.key === columnKey); + if (column) { + column.selected = !column.selected; + } + } - @computed('intl.locale') get defaultDepartment() { return this.intl.t('institutions.dashboard.select_default'); } - @computed('defaultDepartment', 'department', 'departmentMetrics.[]', 'institution') get departments() { let departments = [this.defaultDepartment]; - if (this.institution && this.departmentMetrics) { - const institutionDepartments = this.departmentMetrics.map((x: InstitutionDepartmentsModel) => x.name); + if (this.args.institution && this.args.departmentMetrics) { + const institutionDepartments = this.args.departmentMetrics.map((x: InstitutionDepartmentsModel) => x.name); departments = departments.concat(institutionDepartments); } return departments; } - @computed('defaultDepartment', 'department') get isDefaultDepartment() { return this.department === this.defaultDepartment; } - @computed('department', 'isDefaultDepartment', 'sort') get queryUsers() { const query = {} as Record; if (this.department && !this.isDefaultDepartment) { query['filter[department]'] = this.department; } + if (this.hasOrcid) { + query['filter[orcid_id][ne]'] = ''; + } if (this.sort) { query.sort = this.sort; } return query; } + downloadUrl(format: string) { + const institutionRelationships = this.args.institution.links.relationships; + const usersLink = (institutionRelationships!.user_metrics as RelationshipWithLinks).links.related.href; + const userURL = new URL(usersLink!); + userURL.searchParams.set('format', format); + userURL.searchParams.set('page[size]', '10000'); + Object.entries(this.queryUsers).forEach(([key, value]) => { + userURL.searchParams.set(key, value); + }); + return userURL.toString(); + } + + get downloadCsvUrl() { + return this.downloadUrl('csv'); + } + + get downloadTsvUrl() { + return this.downloadUrl('tsv'); + } + + get downloadJsonUrl() { + return this.downloadUrl('json_report'); + } + @restartableTask @waitFor async searchDepartment(name: string) { await timeout(500); if (this.institution) { - const depts: InstitutionDepartmentsModel[] = await this.institution.queryHasMany('departmentMetrics', { + const depts: InstitutionDepartmentsModel[] = await this.args.institution.queryHasMany('departmentMetrics', { filter: { name, }, @@ -78,19 +236,90 @@ export default class InstitutionalUsersList extends Component { @action onSelectChange(department: string) { - this.analytics.trackFromElement(this.element, { - name: 'Department Select - Change', - category: 'select', - action: 'change', - }); - this.set('department', department); - if (this.reloadUserList) { - this.reloadUserList(); + this.department = department; + } + + @action + sortInstitutionalUsers(sortBy: string) { + if (this.sort === sortBy) { + // If the current sort is ascending, toggle to descending + this.sort = `-${sortBy}`; + } else if (this.sort === `-${sortBy}`) { + // If the current sort is descending, toggle to ascending + this.sort = sortBy; + } else { + // Set to descending if it's a new sort field + this.sort = `-${sortBy}`; + } + } + + @action + cancelSelection() { + this.selectedDepartments = []; + } + + @action + applyColumnSelection() { + this.selectedColumns = this.columns.filter(col => col.selected).map(col => col.key); + } + + @action + toggleOrcidFilter(hasOrcid: boolean) { + this.hasOrcid = hasOrcid; + } + + @action + clickToggleOrcidFilter(hasOrcid: boolean) { + this.hasOrcid = !hasOrcid; + } + + @action + openMessageModal(userId: string) { + this.selectedUserId = userId; + this.messageModalShown = true; + } + + @action + toggleMessageModal(userId: string | null = null) { + this.messageModalShown = !this.messageModalShown; + this.selectedUserId = userId; + if (!this.messageModalShown) { + this.resetModalFields(); } } @action - sortInstitutionalUsers(sort: string) { - this.set('sort', sort); + resetModalFields() { + this.messageText = ''; + this.bccSender = false; + this.replyTo = false; + this.selectedUserId = null; + } + + @task + @waitFor + async sendMessage() { + if (!this.messageText.trim()) { + this.toast.error(this.intl.t('error.empty_message')); + return; + } + + try { + const userMessage = this.store.createRecord('user-message', { + messageText: this.messageText.trim(), + messageType: MessageTypeChoices.InstitutionalRequest, + bccSender: this.bccSender, + replyTo: this.replyTo, + institution: this.args.institution, + messageRecipient: this.selectedUserId, + }); + + await userMessage.save(); + this.toast.success(this.intl.t('institutions.dashboard.send_message_modal.message_sent_success')); + } catch (error) { + this.toast.error(this.intl.t('institutions.dashboard.send_message_modal.message_sent_failed')); + } finally { + this.messageModalShown = false; + } } } diff --git a/app/institutions/dashboard/-components/institutional-users-list/styles.scss b/app/institutions/dashboard/-components/institutional-users-list/styles.scss index ba468a48f50..c68eeb9db69 100644 --- a/app/institutions/dashboard/-components/institutional-users-list/styles.scss +++ b/app/institutions/dashboard/-components/institutional-users-list/styles.scss @@ -1,26 +1,39 @@ .select { max-width: 320px; padding: 7px 16px 7px 14px; - margin-bottom: 15px; border-color: #ddd; border-radius: 2px; - color: #337ab7; + color: $color-select; +} + +.download-dropdown-content { + display: flex; + flex-direction: column; + align-items: flex-start; + border: 1px solid $color-border-gray; + width: max-content; +} + +.downlaod-link { + padding: 4px 8px; } .table { margin-bottom: 45px; table { + overflow-x: auto; + display: block; width: 100%; margin-bottom: 15px; - table-layout: fixed; + table-layout: auto; box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + border-collapse: collapse; } th, td { - padding: 15px; - overflow: hidden; + padding: 10px; text-overflow: ellipsis; white-space: nowrap; } @@ -35,17 +48,8 @@ } .header { - th { - background: #365063; - border: 0; - color: #fff; - text-transform: uppercase; - vertical-align: middle; - } - - .nested-header { - padding: 0 15px; - } + background: #365063; + color: #fff; } .item { @@ -65,20 +69,9 @@ } } -.sort-button { - display: inline; +.sort-arrow { padding-left: 4px; - button, - button:active, - button:focus, - button:focus:active, - button:hover { - padding-top: 0; - height: 1em; - margin-top: -10px; - } - :global(.btn.selected) { color: #fff !important; } @@ -93,6 +86,274 @@ } } -.text-center { +.header-content { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.header-text { + text-overflow: ellipsis; + flex-grow: 1; +} + +.sort-arrow-container { + display: inline-flex; + align-items: center; + margin-left: 4px; +} + +.sort-arrow { + display: inline-block; + vertical-align: middle; + color: #fff; +} + +.select-container { + width: 100%; + display: flex; + justify-content: flex-end; + float: right; +} + +.select { + min-width: 120px; + padding: 7px 16px 7px 14px; + border-color: $color-border-gray; + border-radius: 2px; + color: $color-select; text-align: center; + margin: 15px; + + span { + margin-left: 0; + } +} + +.filter-container { + display: flex; + justify-content: flex-start; + align-items: center; + width: 100%; +} + +.dropdown-panel { + position: absolute; + top: calc(100% + 5px); + right: 0; + background-color: $color-bg-white; + border: 1px solid $color-border-gray; + border-radius: 4px; + padding: 15px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + width: 220px; + + &.mobile { + max-width: none; + } +} + +.dropdown-content { + display: flex; + flex-direction: column; + margin-bottom: 10px; +} + +.dropdown-trigger { + padding: 9px; + color: $color-select; +} + +.dropdown-content label { + display: flex; + align-items: center; + padding: 4px 0; + font-size: 14px; + font-weight: lighter; +} + +.dropdown-content [type='checkbox'] { + margin-right: 8px; + cursor: pointer; +} + +.dropdown-actions { + display: flex; + justify-content: flex-end; + padding-top: 10px; + border-top: 1px solid $color-light; +} + +.icon-columns { + padding-right: 5px; +} + +.filter-controls { + display: flex; + align-items: center; + gap: 20px; +} + +.orcid-switch { + display: flex; + align-items: center; +} + +.orcid-toggle-label { + margin-right: 10px; + font-size: 14px; + color: #333; + white-space: nowrap; + font-weight: lighter; +} + +.switch { + position: relative; + display: inline-flex; + width: 60px; + height: 30px; +} + +.switch input { + opacity: 0; + width: 0; + height: 0; +} + +.slider { + display: flex; + align-items: center; + justify-content: flex-start; + width: 100%; + height: 100%; + border-radius: 34px; + transition: background-color 0.4s; + background-color: #ccc; + cursor: pointer; + position: flex; +} + +.slider::before { + content: ''; + height: 24px; + width: 24px; + background-color: $color-bg-gray; + margin-left: 3px; + border-radius: 50%; + transition: transform 0.4s, background-color 0.4s; + position: flex; +} + +/* Change handle color when checked */ +input:checked + .slider::before { + background-color: $color-green; +} + +/* Hover effects for handle */ +input:not(:checked) + .slider:hover::before { + background-color: $color-bg-gray-darker; +} + +input:checked + .slider:hover::before { + background-color: $color-green-light; +} + +input:checked + .slider::before { + transform: translateX(30px); +} + +.slider.round { + border-radius: 34px; +} + +.slider.round::before { + border-radius: 50%; +} + +.total-users { + margin-right: auto; /* Aligns text to the left */ + display: block ruby; + align-items: center; +} + +.total-users label { + font-size: 18px; + margin-bottom: 0; + font-weight: normal; +} + +.total-users-count { + font-size: 24px; + margin-right: 5px; + font-weight: bold; +} + +.download-button-group { + display: inline-flex; + padding-left: 10px; + align-content: center; + + + .download-dropdown { + margin-left: 3px; + } +} + +.download-dropdown-trigger { + color: $color-link-dark; +} + +.flex { + display: flex; + align-items: center; +} + +.top-wrapper { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; +} + +.right-button-group { + justify-content: flex-end; + margin-right: 15px; +} + + +.icon-message { + opacity: 0; + color: $color-text-blue-dark; + /* !important used to override ember Button border scripting */ + background-color: inherit !important; + border: 0 !important; + box-shadow: 0 !important; +} + +.icon-message:hover { + opacity: 1; + background-color: inherit !important; +} + +.message-textarea { + min-width: 450px; + min-height: 280px; +} + +.message-label { + display: block; +} + +.checkbox-container { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px; +} + +.checkbox-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; } diff --git a/app/institutions/dashboard/-components/institutional-users-list/template.hbs b/app/institutions/dashboard/-components/institutional-users-list/template.hbs index 26aa1a76a95..a38dba87416 100644 --- a/app/institutions/dashboard/-components/institutional-users-list/template.hbs +++ b/app/institutions/dashboard/-components/institutional-users-list/template.hbs @@ -1,80 +1,262 @@ {{#if this.modelTaskInstance.isRunning}} {{else}} - - {{department}} - - - - - {{#let (component 'sort-button' - class=(local-class 'sort-button') - sortAction=(action this.sortInstitutionalUsers) - sort=this.sort - ) as |SortButton|}} - - - {{t 'institutions.dashboard.users_list.name'}} - - - - {{t 'institutions.dashboard.users_list.department'}} - - - - {{t 'institutions.dashboard.users_list.projects'}} - - - - - {{t - - - {{t - - - {{/let}} - - - {{#if institutionalUser}} - - - {{institutionalUser.userName}} ({{institutionalUser.userGuid}}) +
+
+ + {{this.totalUsers}} + + {{t 'institutions.dashboard.users_list.total_users'}} +
+
+
+
+ + +
+ + {{department}} + +
+
+ + + {{#if dd.isOpen}} +
+
+ {{#each this.columns as |column|}} + + {{/each}} +
+
+ + +
+
+ {{/if}} +
+
+ {{#if @institution.linkToExternalReportsArchive}} + + + {{t 'institutions.dashboard.download_past_reports_label'}} + + + {{/if}} +
+ + + + + + + {{t 'institutions.dashboard.format_labels.csv'}} + + + {{t 'institutions.dashboard.format_labels.tsv'}} + + + {{t 'institutions.dashboard.format_labels.json'}} + + + +
+
+
+
+
+ + + {{#let (component 'sort-arrow' + class=(local-class 'sort-arrow') + sortAction=this.sortInstitutionalUsers + sort=this.sort + ) as |SortArrow|}} + + {{#each this.columns as |column|}} + {{#if (includes column.key this.selectedColumns)}} + +
+ {{column.label}} + {{#if column.sort_key}} + + + + {{/if}} +
+ + {{/if}} + {{/each}} + + {{/let}} +
+ + {{#each this.columns as |column|}} + {{#if (includes column.key this.selectedColumns)}} + + {{#if (eq column.type 'user_name')}} + + {{institutionalUser.userName}} + + {{#if @institution.institutionalRequestAccessEnabled}} + + {{/if}} + {{else if (eq column.type 'osf_link')}} + + {{institutionalUser.userGuid}} + + {{else if (eq column.type 'orcid')}} + {{#if institutionalUser.orcidId}} + + {{institutionalUser.orcidId}} + + {{else}} + {{t 'institutions.dashboard.object-list.table-items.missing-info'}} + {{/if}} + {{else if (eq column.type 'date_by_month')}} + {{#if (get institutionalUser column.key)}} + {{moment-format (get institutionalUser column.key) 'MM/YYYY'}} + {{else}} + {{t 'institutions.dashboard.users_list.not_found'}} + {{/if}} + {{else}} + {{get institutionalUser column.key}} + {{/if}} - {{institutionalUser.department}} - {{institutionalUser.publicProjects}} - {{institutionalUser.privateProjects}} - {{else}} - {{placeholder.text lines=1}} - {{placeholder.text lines=1}} - {{placeholder.text lines=1}} - {{placeholder.text lines=1}} {{/if}} - - - {{t 'institutions.dashboard.users_list.empty'}} - -
-
+ {{/each}} + + + {{t 'institutions.dashboard.users_list.empty'}} + + + + + {{t 'institutions.dashboard.send_message_modal.title'}} + + +
+ +