diff --git a/app/adapters/share-adapter.ts b/app/adapters/share-adapter.ts index 5716e5b5b0a..ec624e2b900 100644 --- a/app/adapters/share-adapter.ts +++ b/app/adapters/share-adapter.ts @@ -5,7 +5,7 @@ const osfUrl = config.OSF.url; export default class ShareAdapter extends JSONAPIAdapter { host = config.OSF.shareBaseUrl.replace(/\/$/, ''); // Remove trailing slash to avoid // in URLs - namespace = 'api/v3'; + namespace = 'trove'; queryRecord(store: any, type: any, query: any) { // check if we aren't serving locally, otherwise add accessService query param to card/value searches diff --git a/app/guid-node/registrations/styles.scss b/app/guid-node/registrations/styles.scss index 01662ce2136..36b839d970c 100644 --- a/app/guid-node/registrations/styles.scss +++ b/app/guid-node/registrations/styles.scss @@ -1,4 +1,5 @@ // stylelint-disable max-nesting-depth, selector-max-compound-selectors +@import 'app/styles/components'; .registration-container { margin: 30px; @@ -19,41 +20,7 @@ /* stylelint-disable selector-no-qualifying-type */ ul.tab-list { - margin-bottom: 10px; - border-bottom: 1px solid #ddd; - box-sizing: border-box; - color: rgb(51, 51, 51); - display: block; - line-height: 20px; - list-style-image: none; - list-style-position: outside; - list-style-type: none; - height: 41px; - padding: 0; - } - - /* stylelint-enable selector-no-qualifying-type */ - .tab-list { - li { - display: block; - position: relative; - margin-bottom: -1px; - float: left; - height: 41px; - padding: 10px 15px; - } - - li:global(.ember-tabs__tab--selected) { - background-color: #f8f8f8; - border-bottom: 2px solid #204762; - } - - li:hover { - border-color: transparent; - text-decoration: none; - background-color: #f8f8f8; - color: var(--primary-color); - } + @include tab-list; } } } 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..fd2f9bd1cf9 --- /dev/null +++ b/app/institutions/dashboard/-components/chart-kpi-wrapper/chart-kpi/component-test.ts @@ -0,0 +1,111 @@ +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'); + }); + + /** + * I need to determine if this is going to be a feature or not + test('it renders the without data correctly', async function(this: EnginesIntlTestContext, assert) { + const data = Object({ + total: 0, + title: 'This is the title', + icon: 'building', + }); + + this.set('data', data); + + + await render(hbs` + +`); + + assert.dom('[data-test-kpi-title]') + .hasText('This is the title'); + assert.dom('[data-test-kpi-data]') + .hasText('No data for institution found.'); + assert.dom('[data-test-kpi-icon]') + .hasAttribute('data-icon', 'building'); + }); + */ +}); 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..268b43ef98e --- /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 Private 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 Private 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('Private 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('Private 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..4bc74e2cdbe --- /dev/null +++ b/app/institutions/dashboard/-components/chart-kpi-wrapper/component.ts @@ -0,0 +1,224 @@ +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'; + +/* +import InstitutionSummaryMetricModel from 'ember-osf-web/models/institution-summary-metric'; +*/ + +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-private-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-private-registrations.public'), + total: summaryMetrics.publicRegistrationCount, + } as ChartDataModel, + { + label: this.intl.t('institutions.dashboard.kpi-chart.public-vs-private-registrations.private'), + 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..62c32591dc6 100644 --- a/app/institutions/dashboard/-components/institutional-users-list/component.ts +++ b/app/institutions/dashboard/-components/institutional-users-list/component.ts @@ -1,60 +1,183 @@ -import Component from '@ember/component'; -import { action, computed } from '@ember/object'; -import { reads } from '@ember/object/computed'; +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'; -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; - @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 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.private_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; } @@ -66,7 +189,7 @@ export default class InstitutionalUsersList extends Component { 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 +201,41 @@ 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 - sortInstitutionalUsers(sort: string) { - this.set('sort', sort); + 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; + } + } diff --git a/app/institutions/dashboard/-components/institutional-users-list/styles.scss b/app/institutions/dashboard/-components/institutional-users-list/styles.scss index ba468a48f50..3a2c345a0c8 100644 --- a/app/institutions/dashboard/-components/institutional-users-list/styles.scss +++ b/app/institutions/dashboard/-components/institutional-users-list/styles.scss @@ -1,26 +1,27 @@ .select { max-width: 320px; padding: 7px 16px 7px 14px; - margin-bottom: 15px; border-color: #ddd; border-radius: 2px; - color: #337ab7; + color: $color-select; } .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 +36,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 +57,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 +74,235 @@ } } -.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; + + div { + color: #2d6a9f; + } + + .download-dropdown { + margin-left: 3px; + } +} + +.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; } diff --git a/app/institutions/dashboard/-components/institutional-users-list/template.hbs b/app/institutions/dashboard/-components/institutional-users-list/template.hbs index 26aa1a76a95..c7d7e1d4989 100644 --- a/app/institutions/dashboard/-components/institutional-users-list/template.hbs +++ b/app/institutions/dashboard/-components/institutional-users-list/template.hbs @@ -1,80 +1,188 @@ {{#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}} +
+ + + + + + + + + + + +
+
+
+
+
+ + + {{#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}} + + {{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'}} + + {{/if}} diff --git a/app/institutions/dashboard/-components/object-list/component-test.ts b/app/institutions/dashboard/-components/object-list/component-test.ts new file mode 100644 index 00000000000..4c1ae753cb3 --- /dev/null +++ b/app/institutions/dashboard/-components/object-list/component-test.ts @@ -0,0 +1,168 @@ +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'; + +import { OsfLinkRouterStub } from 'ember-osf-web/tests/integration/helpers/osf-link-router-stub'; + +module('Integration | institutions | dashboard | -components | object-list', hooks => { + setupRenderingTest(hooks); + setupMirage(hooks); + setupIntl(hooks); + + hooks.beforeEach(function(this: TestContext) { + this.owner.unregister('service:router'); + this.owner.register('service:router', OsfLinkRouterStub); + const columns = [ + { + name: 'Title', + sortKey: 'title', + getValue: () => 'Title of some object', + }, + { + name: 'Description', + getValue: () => 'Description of some object', + }, + { + name: 'Contributors', + type: 'contributors', + }, + { + name: 'DOI', + type: 'doi', + }, + ]; + const institution = server.create('institution', { + id: 'my-institution', + }); + const defaultQueryOptions = { + cardSearchFilter: { + resourceType: 'Project,ProjectComponent', + }, + }; + this.setProperties({ + columns, + institution, + defaultQueryOptions, + objectType: 'thingies', + }); + }); + + test('the table headers are correct', async function(assert) { + await render(hbs` + + `); + + // Elements from InstitutionDashboarWrapper are present + assert.dom('[data-test-page-tab="summary"]').exists('Summary tab exists'); + + // Elements in the top bar are present + assert.dom('[data-test-object-count]').containsText('10 total thingies', 'Object count is correct'); + assert.dom('[data-test-toggle-filter-button]').exists('Filter button exists'); + assert.dom('[data-test-customize-columns-button]').exists('Columns button exists'); + + assert.dom('[data-test-object-list-table]').exists('Object list exists'); + + // The table headers are correct + assert.dom('[data-test-column-header]').exists({ count: 4 }, 'There are 4 columns'); + assert.dom('[data-test-column-header="Title"]').containsText('Title'); + assert.dom('[data-test-column-header="Title"] [data-test-sort="title"]').exists('Title is sortable'); + assert.dom('[data-test-column-header="Description"]').containsText('Description'); + assert.dom('[data-test-column-header="Description"] [data-test-sort="description"]') + .doesNotExist('Description is not sortable'); + + // The table data is not blatantly incorrect + assert.dom('[data-test-object-table-body-row]').exists({ count: 10 }, 'There are 10 rows'); + }); + + test('the table supports filtering', async function(assert) { + await render(hbs` + + `); + + await click('[data-test-toggle-filter-button]'); + + assert.dom('[data-test-filter-facet-toggle]').exists({ count: 3 }, '3 filters available'); + + // Open the filter facet and load the values and select the first filter value + await click('[data-test-filter-facet-toggle]'); + await click('[data-test-filter-facet-value] button'); + + assert.dom('[data-test-active-filter]').exists({ count: 1 }, '1 filter active'); + assert.dom('[data-test-remove-active-filter]').exists('Remove filter button exists'); + + await click('[data-test-remove-active-filter]'); + assert.dom('[data-test-active-filter]').doesNotExist('Filter removed'); + }); + + test('the table supports customizing columns', async function(assert) { + await render(hbs` + + `); + + assert.dom('[data-test-column-header]').exists({ count: 4 }, '4 columns available'); + const titleColumn = document.querySelector('[data-test-column-header="Title"]'); + assert.ok(titleColumn, 'Title column is visible'); + + // Open the column customization menu + await click('[data-test-customize-columns-button]'); + assert.dom('[data-test-column-toggle-input]').exists({ count: 4 }, '4 columns available to show/hide'); + assert.dom('[data-test-column-toggle-input="Title"]').isChecked('Title column checkbox is checked'); + assert.dom('[data-test-column-toggle-input="Description"]').isChecked('Description column checkbox is checked'); + + // Toggle off the first column + await click('[data-test-column-toggle-input="Title"]'); + assert.ok(titleColumn, 'Title column still visible after toggling off'); + + // Save changes + await click('[data-test-save-columns-button]'); + assert.dom('[data-test-column-toggle-input]').doesNotExist('Column toggle menu hidden'); + assert.dom('[data-test-column-header="Title"]').doesNotExist('Title column removed'); + assert.dom('[data-test-column-header]').exists({ count: 3 }, '3 columns available'); + + // Open the menu again + await click('[data-test-customize-columns-button]'); + assert.dom('[data-test-column-toggle-input]').exists({ count: 4 }, 'Column toggle menu reopened'); + assert.dom('[data-test-column-toggle-input="Title"]').isNotChecked('Title column checkbox is not checked'); + assert.dom('[data-test-column-toggle-input="Description"]') + .isChecked('Description column checkbox is still checked'); + + // Toggle off all columns, but reset + await click('[data-test-column-toggle-input="Description"]'); + await click('[data-test-column-toggle-input="Contributors"]'); + await click('[data-test-column-toggle-input="DOI"]'); + await click('[data-test-reset-columns-button]'); + assert.dom('[data-test-column-toggle-input]').doesNotExist('Column toggle menu hidden'); + assert.dom('[data-test-column-header]').exists({ count: 3 }, '3 columns available, as we did not save changes'); + + // Open the menu again + await click('[data-test-customize-columns-button]'); + assert.dom('[data-test-column-toggle-input]').exists({ count: 4 }, 'Column toggle menu reopened'); + assert.dom('[data-test-column-toggle-input="Title"]').isNotChecked('Title column checkbox is not checked'); + assert.dom('[data-test-column-toggle-input="Description"]') + .isChecked('Description column checkbox is still checked'); + + // Toggle title back on + await click('[data-test-column-toggle-input="Title"]'); + await click('[data-test-save-columns-button]'); + assert.ok(titleColumn, 'Title column visible again'); + }); +}); diff --git a/app/institutions/dashboard/-components/object-list/component.ts b/app/institutions/dashboard/-components/object-list/component.ts new file mode 100644 index 00000000000..0333bfb59ad --- /dev/null +++ b/app/institutions/dashboard/-components/object-list/component.ts @@ -0,0 +1,120 @@ +import { action } from '@ember/object'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +import InstitutionModel from 'ember-osf-web/models/institution'; +import { SuggestedFilterOperators } from 'ember-osf-web/models/related-property-path'; +import SearchResultModel from 'ember-osf-web/models/search-result'; +import { Filter } from 'osf-components/components/search-page/component'; + +interface Column { + name: string; + sortKey?: string; + sortParam?: string; +} +interface ValueColumn extends Column { + getValue(searchResult: SearchResultModel): string; +} + +interface LinkColumn extends Column { + getHref(searchResult: SearchResultModel): string; + getLinkText(searchResult: SearchResultModel): string; + type: 'link'; +} + +interface ComponentColumn extends Column { + type: 'doi' | 'contributors'; +} + +export type ObjectListColumn = ValueColumn | LinkColumn | ComponentColumn; + +interface InstitutionalObjectListArgs { + institution: InstitutionModel; + defaultQueryOptions: Record<'cardSearchFilter', Record>; + columns: ObjectListColumn[]; + objectType: string; +} + +export default class InstitutionalObjectList extends Component { + @tracked activeFilters: Filter[] = []; + @tracked page = ''; + @tracked sort = '-dateModified'; + @tracked sortParam?: string; + @tracked visibleColumns = this.args.columns.map(column => column.name); + @tracked dirtyVisibleColumns = [...this.visibleColumns]; // track changes to visible columns before they are saved + + get queryOptions() { + const options = { + cardSearchFilter: { + ...this.args.defaultQueryOptions.cardSearchFilter, + }, + 'page[cursor]': this.page, + 'page[size]': 10, + // sort can look like `sort=dateFieldName` or `sort[integer-value]=fieldName` if sortParam is provided + sort: this.sortParam ? { [this.sortParam]: this.sort } : this.sort, + }; + const fullQueryOptions = this.activeFilters.reduce((acc, filter: Filter) => { + if (filter.suggestedFilterOperator === SuggestedFilterOperators.IsPresent) { + acc.cardSearchFilter[filter.propertyPathKey] = {}; + acc.cardSearchFilter[filter.propertyPathKey][filter.value] = true; + return acc; + } + const currentValue = acc.cardSearchFilter[filter.propertyPathKey]; + acc.cardSearchFilter[filter.propertyPathKey] = + currentValue ? currentValue.concat(filter.value) : [filter.value]; + return acc; + }, options); + return fullQueryOptions; + } + + get valueSearchQueryOptions() { + return { + ...this.queryOptions.cardSearchFilter, + }; + } + + @action + updateVisibleColumns() { + this.visibleColumns = [...this.dirtyVisibleColumns]; + } + + @action + resetColumns() { + this.dirtyVisibleColumns = [...this.visibleColumns]; + } + + @action + toggleColumnVisibility(columnName: string) { + if (this.dirtyVisibleColumns.includes(columnName)) { + this.dirtyVisibleColumns.removeObject(columnName); + } else { + this.dirtyVisibleColumns.pushObject(columnName); + } + } + + @action + toggleFilter(property: Filter) { + this.page = ''; + if (this.activeFilters.includes(property)) { + this.activeFilters.removeObject(property); + } else { + this.activeFilters.pushObject(property); + } + } + + @action + updateSortKey(newSortKey: string, newSortParam?: string) { + this.sortParam = newSortParam; + this.page = ''; + if (this.sort === newSortKey) { + this.sort = '-' + newSortKey; + } else { + this.sort = newSortKey; + } + } + + @action + updatePage(newPage: string) { + this.page = newPage; + } +} diff --git a/app/institutions/dashboard/-components/object-list/contributors-field/component.ts b/app/institutions/dashboard/-components/object-list/contributors-field/component.ts new file mode 100644 index 00000000000..d4923162959 --- /dev/null +++ b/app/institutions/dashboard/-components/object-list/contributors-field/component.ts @@ -0,0 +1,75 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import Intl from 'ember-intl/services/intl'; + +import InstitutionModel from 'ember-osf-web/models/institution'; +import SearchResultModel from 'ember-osf-web/models/search-result'; +import { AttributionRoleIris } from 'ember-osf-web/models/index-card'; +import { getOsfmapObjects, getSingleOsfmapValue, hasOsfmapValue } from 'ember-osf-web/packages/osfmap/jsonld'; + +interface ContributorsFieldArgs { + searchResult: SearchResultModel; + institution: InstitutionModel; +} + +const roleIriToTranslationKey: Record = { + [AttributionRoleIris.Admin]: 'general.permissions.admin', + [AttributionRoleIris.Write]: 'general.permissions.write', + [AttributionRoleIris.Read]: 'general.permissions.read', +}; + + +export default class InstitutionalObjectListContributorsField extends Component { + @service intl!: Intl; + + // Return two contributors affiliated with the institution given with highest permission levels + get topInstitutionAffiliatedContributors() { + const { searchResult, institution } = this.args; + const {resourceMetadata} = searchResult; + const attributions: any[] = getOsfmapObjects(resourceMetadata, ['qualifiedAttribution']); + const contributors = getOsfmapObjects(resourceMetadata, ['creator']); + const institutionIris = institution.iris; + + const affiliatedAttributions = attributions + .filter((attribution: any) => hasInstitutionAffiliation(contributors, attribution, institutionIris)); + const adminAttributions = affiliatedAttributions.filter( + attribution => hasOsfmapValue(attribution, ['hadRole'], AttributionRoleIris.Admin), + ); + const writeAttributions = affiliatedAttributions.filter( + attribution => hasOsfmapValue(attribution, ['hadRole'], AttributionRoleIris.Write), + ); + const readAttributions = affiliatedAttributions.filter( + attribution => hasOsfmapValue(attribution, ['hadRole'], AttributionRoleIris.Read), + ); + + const prioritizedAttributions = adminAttributions.concat(writeAttributions, readAttributions); + + return prioritizedAttributions.slice(0, 2).map(attribution => { + const contributor = getContributorById(contributors, getSingleOsfmapValue(attribution, ['agent'])); + const roleIri: AttributionRoleIris = getSingleOsfmapValue(attribution, ['hadRole']); + return { + name: getSingleOsfmapValue(contributor,['name']), + url: getSingleOsfmapValue(contributor, ['identifier']), + permissionLevel: this.intl.t(roleIriToTranslationKey[roleIri]), + }; + }); + } +} + +function hasInstitutionAffiliation(contributors: any[], attribution: any, institutionIris: string[]) { + const attributedContributor = getContributorById(contributors, getSingleOsfmapValue(attribution, ['agent'])); + + if (!attributedContributor.affiliation) { + return false; + } + + return attributedContributor.affiliation.some( + (affiliation: any) => affiliation.identifier.some( + (affiliationIdentifier: any) => institutionIris.includes(affiliationIdentifier['@value']), + ), + ); +} + +function getContributorById(contributors: any[], contributorId: string) { + return contributors.find(contributor => contributor['@id'] === contributorId); +} diff --git a/app/institutions/dashboard/-components/object-list/contributors-field/template.hbs b/app/institutions/dashboard/-components/object-list/contributors-field/template.hbs new file mode 100644 index 00000000000..992e6be26a3 --- /dev/null +++ b/app/institutions/dashboard/-components/object-list/contributors-field/template.hbs @@ -0,0 +1,14 @@ +{{#each this.topInstitutionAffiliatedContributors as |contributor|}} +
+ + {{contributor.name}} + + {{t 'institutions.dashboard.object-list.table-items.permission-level' permissionLevel=contributor.permissionLevel}} +
+{{else}} +
+ {{t 'institutions.dashboard.object-list.table-items.no-contributors'}} +
+{{/each}} diff --git a/app/institutions/dashboard/-components/object-list/doi-field/component.ts b/app/institutions/dashboard/-components/object-list/doi-field/component.ts new file mode 100644 index 00000000000..5a1e2733b16 --- /dev/null +++ b/app/institutions/dashboard/-components/object-list/doi-field/component.ts @@ -0,0 +1,15 @@ +import Component from '@glimmer/component'; + +import SearchResultModel from 'ember-osf-web/models/search-result'; +import { extractDoi } from 'ember-osf-web/utils/doi'; + +interface DoiFieldArgs { + searchResult: SearchResultModel; +} + +export default class InstitutionalObjectListDoiField extends Component { + get dois() { + const dois = this.args.searchResult.doi; + return dois.map((doi: string) => ({ fullLink: doi, displayText: extractDoi(doi) })); + } +} diff --git a/app/institutions/dashboard/-components/object-list/doi-field/template.hbs b/app/institutions/dashboard/-components/object-list/doi-field/template.hbs new file mode 100644 index 00000000000..59496096bbc --- /dev/null +++ b/app/institutions/dashboard/-components/object-list/doi-field/template.hbs @@ -0,0 +1,5 @@ +{{#each this.dois as |doi|}} + {{doi.displayText}} +{{else}} + {{t 'institutions.dashboard.object-list.table-items.missing-info'}} +{{/each}} diff --git a/app/institutions/dashboard/-components/object-list/styles.scss b/app/institutions/dashboard/-components/object-list/styles.scss new file mode 100644 index 00000000000..5eabae93733 --- /dev/null +++ b/app/institutions/dashboard/-components/object-list/styles.scss @@ -0,0 +1,130 @@ +@import 'app/styles/layout'; + +.top-bar-wrapper { + display: flex; + justify-content: space-between; + align-items: center; + margin: 1rem 0; +} + +.total-object-count { + align-self: center; + font-size: large; + + .total-object-number { + font-weight: bold; + } +} + +.top-bar-button-wrapper { + display: flex; + + button { + margin-right: 0.5rem; + } +} + +.customize-menu-wrapper { + display: flex; + flex-direction: column; + padding: 0.5rem; + border: 1px solid $color-border-gray; + width: 240px; + + label { + text-wrap: nowrap; + } +} + +.customize-menu-button-wrapper { + display: flex; + justify-content: end; +} + +.table-wrapper { + overflow: auto; +} + +.object-table { + border-collapse: collapse; + + th { + padding: 10px 15px; + + span { + display: flex; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + } + + td { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + padding: 10px 15px; + + border: 1px solid $color-border-gray; + } +} + +.object-table-head { + background: $color-bg-gray-blue-dark; + color: $color-text-white; +} + +.bottom-bar-wrapper { + display: flex; + justify-content: end; + margin: 1rem 0; + + button { + margin-left: 0.5rem; + } +} + +.right-wrapper { + min-width: 300px; + padding: 0.5rem; +} + +.right-panel-header { + font-size: 1.5rem; +} + +.close-button { + float: right; +} + +.active-filter-list { + list-style: none; + padding-left: 0; + margin-top: 1rem; + border-top: 1px solid $color-border-gray; + border-bottom: 1px solid $color-border-gray; +} + +.active-filter-item { + display: flex; + margin: 0.5rem 0.2rem; + justify-content: space-between; + + button { + margin-right: -5px; + } +} + +.blue-text-button { + color: $color-link-dark; +} + +.download-dropdown-trigger { + color: $color-bg-blue-dark; +} + +.download-button-group { + align-content: center; + display: inline-flex; + padding-left: 10px; +} diff --git a/app/institutions/dashboard/-components/object-list/template.hbs b/app/institutions/dashboard/-components/object-list/template.hbs new file mode 100644 index 00000000000..2015b283e94 --- /dev/null +++ b/app/institutions/dashboard/-components/object-list/template.hbs @@ -0,0 +1,259 @@ + + + + {{#if list.searchObjectsTask.isRunning}} + + {{else}} +
+ + {{list.totalResultCount}} + {{t 'institutions.dashboard.object-list.total-objects' objectType=@objectType}} + +
+ + + + + {{t 'institutions.dashboard.object-list.customize'}} + + + {{#each @columns as |column|}} + + {{/each}} +
+ + +
+
+
+
+ {{#if @institution.linkToExternalReportsArchive}} +
+ + + {{t 'institutions.dashboard.download_past_reports_label'}} + + + +
+ {{/if}} + + + + + + + + + +
+
+
+
+ + + + {{#let (component 'sort-arrow' + sort=this.sort + ) as |SortArrow| + }} + {{#each @columns as |column|}} + {{#if (includes column.name this.visibleColumns)}} + + {{/if}} + {{/each}} + {{/let}} + + + + + {{#each list.searchResults as |result|}} + + {{#each @columns as |column|}} + {{#if (includes column.name this.visibleColumns)}} + + {{/if}} + {{/each}} + + {{/each}} + +
+ + {{column.name}} + {{#if column.sortKey}} + + {{/if}} + +
+ {{#if (eq column.type 'link')}} + + {{call (fn column.getLinkText result)}} + + {{else if (eq column.type 'doi')}} + + {{else if (eq column.type 'contributors')}} + + {{else}} + {{call (fn column.getValue result)}} + {{/if}} +
+
+
+ {{#if list.showFirstPageOption}} + + {{/if}} + {{#if list.hasPrevPage}} + + {{/if}} + {{#if list.hasNextPage}} + + {{/if}} +
+ {{/if}} +
+ {{#if list.relatedProperties}} + + + {{t 'institutions.dashboard.object-list.filter-heading'}} + + + {{#if this.activeFilters}} +
    + {{#each this.activeFilters as |filter|}} +
  • + + {{filter.propertyVisibleLabel}}: + {{filter.label}} + + +
  • + {{/each}} +
+ {{/if}} + + {{#each list.relatedProperties as |property|}} + + {{/each}} + {{#if list.booleanFilters.length}} + + {{/if}} +
+ {{/if}} +
+
diff --git a/app/institutions/dashboard/-components/panel/styles.scss b/app/institutions/dashboard/-components/panel/styles.scss deleted file mode 100644 index cba6f52f7da..00000000000 --- a/app/institutions/dashboard/-components/panel/styles.scss +++ /dev/null @@ -1,34 +0,0 @@ -.panel { - .panel-overall { - border: 0; - margin-bottom: 30px; - box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); - } - - .panel-heading { - background: #365063; - padding: 15px; - border: 0; - } - - .panel-title { - display: block; - float: none; - color: #fff; - font-size: 14px; - font-weight: bold; - text-align: center; - text-transform: uppercase; - line-height: 20px; - } - - .panel-body { - color: #263947; - text-align: center; - - h3 { - font-size: 18pt; - font-weight: 800; - } - } -} diff --git a/app/institutions/dashboard/-components/panel/template.hbs b/app/institutions/dashboard/-components/panel/template.hbs deleted file mode 100644 index d8d1e4fa52f..00000000000 --- a/app/institutions/dashboard/-components/panel/template.hbs +++ /dev/null @@ -1,12 +0,0 @@ - - - {{@title}} - - - {{#if @isLoading}} - - {{else}} -
{{yield}}
- {{/if}} -
-
\ No newline at end of file diff --git a/app/institutions/dashboard/-components/projects-panel/component.ts b/app/institutions/dashboard/-components/projects-panel/component.ts deleted file mode 100644 index 1e30b36108f..00000000000 --- a/app/institutions/dashboard/-components/projects-panel/component.ts +++ /dev/null @@ -1,47 +0,0 @@ -import Component from '@ember/component'; -import { computed } from '@ember/object'; -import { alias } from '@ember/object/computed'; -import { inject as service } from '@ember/service'; -import { ChartData, ChartOptions } from 'ember-cli-chart'; -import Intl from 'ember-intl/services/intl'; - -import InstitutionSummaryMetricModel from 'ember-osf-web/models/institution-summary-metric'; - -export default class ProjectsPanel extends Component { - summaryMetrics!: InstitutionSummaryMetricModel; - @alias('summaryMetrics.privateProjectCount') numPrivateProjects!: number; - @alias('summaryMetrics.publicProjectCount') numPublicProjects!: number; - @service intl!: Intl; - - chartOptions: ChartOptions = { - aspectRatio: 1, - legend: { - display: false, - }, - }; - - @computed('numPrivateProjects', 'numPublicProjects') - get numProjects(): number { - return this.numPublicProjects + this.numPrivateProjects; - } - - @computed('numPrivateProjects', 'numPublicProjects') - get chartData(): ChartData { - return { - labels: [ - this.intl.t('institutions.dashboard.public'), - this.intl.t('institutions.dashboard.private'), - ], - datasets: [{ - data: [ - this.numPublicProjects, - this.numPrivateProjects, - ], - backgroundColor: [ - '#36b183', - '#a5b3bd', - ], - }], - }; - } -} diff --git a/app/institutions/dashboard/-components/projects-panel/styles.scss b/app/institutions/dashboard/-components/projects-panel/styles.scss deleted file mode 100644 index 51603b6aad1..00000000000 --- a/app/institutions/dashboard/-components/projects-panel/styles.scss +++ /dev/null @@ -1,13 +0,0 @@ -.ember-chart { - max-width: 200px; - max-height: 200px; - margin: 0 auto 15px; -} - -.projects-count { - font-size: 16px; - - span:first-of-type { - margin-right: 15px; - } -} diff --git a/app/institutions/dashboard/-components/projects-panel/template.hbs b/app/institutions/dashboard/-components/projects-panel/template.hbs deleted file mode 100644 index a9a5bc68fd6..00000000000 --- a/app/institutions/dashboard/-components/projects-panel/template.hbs +++ /dev/null @@ -1,25 +0,0 @@ -{{#if this.summaryMetrics}} -
- -
-

{{this.numProjects}}

-
- - {{this.numPublicProjects}} - {{t 'institutions.dashboard.public'}} - - - {{this.numPrivateProjects}} - {{t 'institutions.dashboard.private'}} - -
-{{else}} - {{t 'institutions.dashboard.empty'}} -{{/if}} \ No newline at end of file diff --git a/app/institutions/dashboard/-components/total-count-kpi-wrapper/component-test.ts b/app/institutions/dashboard/-components/total-count-kpi-wrapper/component-test.ts new file mode 100644 index 00000000000..7df5335449e --- /dev/null +++ b/app/institutions/dashboard/-components/total-count-kpi-wrapper/component-test.ts @@ -0,0 +1,118 @@ +import { 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 | total-count-kpi-wrapper', hooks => { + setupRenderingTest(hooks); + setupMirage(hooks); + setupIntl(hooks); + + hooks.beforeEach(function(this: TestContext) { + const model = Object({ + summaryMetrics: { + publicProjectCount: 10, + privateProjectCount: 10, + userCount: 10, + publicRegistrationCount: 100, + publishedPreprintCount: 1000, + embargoedRegistrationCount: 200, + storageByteCount: 104593230, + publicFileCount: 1567, + monthlyLoggedInUserCount: 300, + monthlyActiveUserCount:40, + convertedStorageCount: '104 GB', + }, + }); + + this.set('model', model); + }); + + test('it renders the dashboard total kpis correctly', async assert => { + // Given the component is rendered + await render(hbs` + +`); + + let parentContainer = '[data-test-total-count-kpi="0"]'; + // Then the total users kpi is tested + assert.dom(parentContainer) + .exists('The User Widget exists'); + assert.dom(parentContainer) + .hasText('10 Total Users'); + assert.dom(`${parentContainer} [data-test-kpi-icon]`) + .hasAttribute('data-icon', 'users'); + + // And the total logged in user kpi is tested + parentContainer = '[data-test-total-count-kpi="1"]'; + assert.dom(parentContainer) + .exists('The Total Monthly Logged in Users Widget exists'); + assert.dom(parentContainer) + .hasText('300 Total Monthly Logged in Users'); + assert.dom(`${parentContainer} [data-test-kpi-icon]`) + .hasAttribute('data-icon', 'users'); + + // And the total active in user kpi is tested + parentContainer = '[data-test-total-count-kpi="2"]'; + assert.dom(parentContainer) + .exists('The Total Monthly Active Users Widget exists'); + assert.dom(parentContainer) + .hasText('40 Total Monthly Active Users'); + assert.dom(`${parentContainer} [data-test-kpi-icon]`) + .hasAttribute('data-icon', 'users'); + + // And the total project kpi is tested + parentContainer = '[data-test-total-count-kpi="3"]'; + assert.dom(parentContainer) + .exists('The Project Widget exists'); + assert.dom(parentContainer) + .hasText('20 OSF Public and Private Projects'); + assert.dom(`${parentContainer} [data-test-kpi-icon]`) + .hasAttribute('data-icon', 'flask'); + + // And the total registration kpi is tested + parentContainer = '[data-test-total-count-kpi="4"]'; + assert.dom(parentContainer) + .exists('The Total Registration Widget exists'); + assert.dom(parentContainer) + .hasText('300 OSF Public and Embargoed Registrations'); + assert.dom(`${parentContainer} [data-test-kpi-icon]`) + .hasAttribute('data-icon', 'archive'); + + // And the total preprint kpi is tested + parentContainer = '[data-test-total-count-kpi="5"]'; + assert.dom(parentContainer) + .exists('The Total Preprint Widget exists'); + assert.dom(parentContainer) + .hasText('1000 OSF Preprints'); + assert.dom(`${parentContainer} [data-test-kpi-icon]`) + .hasAttribute('data-icon', 'file-alt'); + + // And the total file count kpi is tested + parentContainer = '[data-test-total-count-kpi="6"]'; + assert.dom(parentContainer) + .exists('The Total File Widget exists'); + assert.dom(parentContainer) + .hasText('1567 Total Public File Count'); + assert.dom(`${parentContainer} [data-test-kpi-icon]`) + .hasAttribute('data-icon', 'file-alt'); + + // And the total storage kpi is tested + parentContainer = '[data-test-total-count-kpi="7"]'; + assert.dom(parentContainer) + .exists('The Total Storage Widget exists'); + assert.dom(parentContainer) + .hasText('104 Total Storage in GB'); + assert.dom(`${parentContainer} [data-test-kpi-icon]`) + .hasAttribute('data-icon', 'database'); + + // Finally there are only 8 widgets + assert.dom('[data-test-total-count-kpi="8"]') + .doesNotExist('There are only 8 widgets'); + }); +}); diff --git a/app/institutions/dashboard/-components/total-count-kpi-wrapper/component.ts b/app/institutions/dashboard/-components/total-count-kpi-wrapper/component.ts new file mode 100644 index 00000000000..ccc9c62e102 --- /dev/null +++ b/app/institutions/dashboard/-components/total-count-kpi-wrapper/component.ts @@ -0,0 +1,101 @@ +import { waitFor } from '@ember/test-waiters'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { task } from 'ember-concurrency'; +import { taskFor } from 'ember-concurrency-ts'; +import Intl from 'ember-intl/services/intl'; +import { inject as service } from '@ember/service'; +import InstitutionSummaryMetricModel from 'ember-osf-web/models/institution-summary-metric'; + +interface TotalCountKpiWrapperArgs { + model: any; +} + +export interface TotalCountKpiModel { + title: string; + total: number | string; + icon: string; +} + +export default class TotalCountKpiWrapperComponent extends Component { + @service intl!: Intl; + @tracked model = this.args.model; + @tracked totalCountKpis = [] as TotalCountKpiModel[]; + @tracked isLoading = true; + + constructor(owner: unknown, args: TotalCountKpiWrapperArgs) { + super(owner, args); + + taskFor(this.loadData).perform(); + } + + /** + * 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): number { + return summaryMetrics.privateProjectCount + summaryMetrics.publicProjectCount; + } + + private calculateRegistrations(summaryMetrics: InstitutionSummaryMetricModel): number { + return summaryMetrics.embargoedRegistrationCount + summaryMetrics.publicRegistrationCount; + } + + @task + @waitFor + private async loadData(): Promise { + const metrics: { summaryMetrics: InstitutionSummaryMetricModel } = await this.model; + const [storageAmount, storageUnit] = metrics.summaryMetrics.convertedStorageCount.split(' '); + + this.totalCountKpis.push( + { + title: this.intl.t('institutions.dashboard.kpi-panel.users'), + total: metrics.summaryMetrics.userCount, + icon: 'users', + }, + { + title: this.intl.t('institutions.dashboard.kpi-panel.logged-in-users'), + total: metrics.summaryMetrics.monthlyLoggedInUserCount, + icon: 'users', + }, + { + title: this.intl.t('institutions.dashboard.kpi-panel.active-users'), + total: metrics.summaryMetrics.monthlyActiveUserCount, + icon: 'users', + }, + { + title: this.intl.t('institutions.dashboard.kpi-panel.projects'), + total: this.calculateProjects(metrics.summaryMetrics), + icon: 'flask', + }, + { + title: this.intl.t('institutions.dashboard.kpi-panel.registrations'), + total: this.calculateRegistrations(metrics.summaryMetrics), + icon: 'archive', + }, + { + title: this.intl.t('institutions.dashboard.kpi-panel.preprints'), + total: metrics.summaryMetrics.publishedPreprintCount, + icon: 'file-alt', + }, + { + title: this.intl.t('institutions.dashboard.kpi-panel.file-count'), + total: metrics.summaryMetrics.publicFileCount, + icon: 'file-alt', + }, + { + title: this.intl.t('institutions.dashboard.kpi-panel.storage', { unit: storageUnit }), + total: storageAmount, + icon: 'database', + }, + ); + + this.isLoading = false; + } +} + + diff --git a/app/institutions/dashboard/-components/total-count-kpi-wrapper/styles.scss b/app/institutions/dashboard/-components/total-count-kpi-wrapper/styles.scss new file mode 100644 index 00000000000..853bdec6f24 --- /dev/null +++ b/app/institutions/dashboard/-components/total-count-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: 145px; + 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: 145px; + } + + &.mobile { + flex-direction: column; + height: fit-content; + align-items: center; + margin-bottom: 0; + } +} diff --git a/app/institutions/dashboard/-components/total-count-kpi-wrapper/template.hbs b/app/institutions/dashboard/-components/total-count-kpi-wrapper/template.hbs new file mode 100644 index 00000000000..943bca40371 --- /dev/null +++ b/app/institutions/dashboard/-components/total-count-kpi-wrapper/template.hbs @@ -0,0 +1,14 @@ +
+ {{#if this.isLoading}} +
+ +
+ {{else}} + {{#each this.totalCountKpis as |totalCountKpi index|}} + + {{/each}} + {{/if}} +
\ No newline at end of file diff --git a/app/institutions/dashboard/-components/total-count-kpi-wrapper/total-count-kpi/component-test.ts b/app/institutions/dashboard/-components/total-count-kpi-wrapper/total-count-kpi/component-test.ts new file mode 100644 index 00000000000..69897968789 --- /dev/null +++ b/app/institutions/dashboard/-components/total-count-kpi-wrapper/total-count-kpi/component-test.ts @@ -0,0 +1,64 @@ +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { EnginesIntlTestContext } from 'ember-engines/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 | total-count-kpi', hooks => { + setupRenderingTest(hooks); + setupMirage(hooks); + setupIntl(hooks); + + hooks.beforeEach(function(this: TestContext) { + const data = Object({ + total: 200, + title: 'This is the title', + icon: 'building', + }); + + this.set('data', data); + }); + + test('it renders the data correctly', async assert => { + + await render(hbs` + +`); + + assert.dom('[data-test-kpi-title]') + .hasText('This is the title'); + assert.dom('[data-test-kpi-data]') + .hasText('200'); + assert.dom('[data-test-kpi-icon]') + .hasAttribute('data-icon', 'building'); + }); + + test('it renders the without data correctly', async function(this: EnginesIntlTestContext, assert) { + const data = Object({ + total: 0, + title: 'This is the title', + icon: 'building', + }); + + this.set('data', data); + + + await render(hbs` + +`); + + assert.dom('[data-test-kpi-title]') + .hasText('This is the title'); + assert.dom('[data-test-kpi-data]') + .hasText('No data for institution found.'); + assert.dom('[data-test-kpi-icon]') + .hasAttribute('data-icon', 'building'); + }); +}); diff --git a/app/institutions/dashboard/-components/total-count-kpi-wrapper/total-count-kpi/styles.scss b/app/institutions/dashboard/-components/total-count-kpi-wrapper/total-count-kpi/styles.scss new file mode 100644 index 00000000000..ad500d44b1b --- /dev/null +++ b/app/institutions/dashboard/-components/total-count-kpi-wrapper/total-count-kpi/styles.scss @@ -0,0 +1,67 @@ +// stylelint-disable max-nesting-depth, selector-max-compound-selectors + +.kpi-container { + margin-right: 12px; + margin-bottom: 12px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: flex-start; + width: 350px; + height: 140px; + background-color: $color-bg-white; + + .top-container { + width: 100%; + display: flex; + justify-content: space-between; + padding: 0 15px; + margin-bottom: 10px; + + .left-container { + padding-left: 5px; + height: 75px; + display: flex; + justify-content: flex-start; + align-items: center; + + .total-container { + font-size: 84px; + font-style: normal; + font-weight: bolder; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + } + + .right-container { + padding-right: 10px; + height: 75px; + width: 75px; + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: center; + + .icon { + font-size: 60px; + color: $color-text-slate-gray; + } + } + + } + + .title { + padding-left: 15px; + width: calc(100% - 15px); + font-size: 14px; + font-weight: normal; + height: 25px; + } + + &.mobile { + margin-right: 0; + margin-bottom: 12px; + } +} diff --git a/app/institutions/dashboard/-components/total-count-kpi-wrapper/total-count-kpi/template.hbs b/app/institutions/dashboard/-components/total-count-kpi-wrapper/total-count-kpi/template.hbs new file mode 100644 index 00000000000..59484f26916 --- /dev/null +++ b/app/institutions/dashboard/-components/total-count-kpi-wrapper/total-count-kpi/template.hbs @@ -0,0 +1,20 @@ +
+
+
+ {{#if @data.total}} +
+ {{@data.total}} +
+ {{else}} + {{t 'institutions.dashboard.empty'}} + {{/if}} +
+
+ +
+
+
{{@data.title}}
+
diff --git a/app/institutions/dashboard/controller.ts b/app/institutions/dashboard/controller.ts deleted file mode 100644 index 785f77100c5..00000000000 --- a/app/institutions/dashboard/controller.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { alias } from '@ember/object/computed'; -import Controller from '@ember/controller'; -import { computed } from '@ember/object'; -import { inject as service } from '@ember/service'; - -import { InstitutionsDashboardModel } from 'ember-osf-web/institutions/dashboard/route'; -import InstitutionModel from 'ember-osf-web/models/institution'; -import InstitutionDepartmentModel from 'ember-osf-web/models/institution-department'; -import InstitutionSummaryMetricModel from 'ember-osf-web/models/institution-summary-metric'; -import CurrentUser from 'ember-osf-web/services/current-user'; -import { addQueryParam } from 'ember-osf-web/utils/url-parts'; - -export default class InstitutionsDashboardController extends Controller { - @service currentUser!: CurrentUser; - - @alias('model.taskInstance.value') modelValue?: InstitutionsDashboardModel; - @alias('modelValue.institution') institution?: InstitutionModel; - @alias('modelValue.summaryMetrics') summaryMetrics?: InstitutionSummaryMetricModel; - @alias('modelValue.departmentMetrics') departmentMetrics?: InstitutionDepartmentModel[]; - @alias('modelValue.totalUsers') totalUsers?: number; - - @computed('institution') - get csvHref(): string { - const { institution } = this; - if (institution) { - const url = institution.hasMany('userMetrics').link(); - return addQueryParam(url, 'format', 'csv'); - } - return ''; - } -} - -declare module '@ember/controller' { - interface Registry { - 'institutions-dashboard': InstitutionsDashboardController; - } -} diff --git a/app/institutions/dashboard/index/styles.scss b/app/institutions/dashboard/index/styles.scss new file mode 100644 index 00000000000..5df5cd3eeaf --- /dev/null +++ b/app/institutions/dashboard/index/styles.scss @@ -0,0 +1,24 @@ +.main-container { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + padding-top: 12px; + background-color: $color-bg-gray; + width: 100%; + + .kpi-container { + margin-bottom: 12px; + } + + .chart-container { + margin-bottom: 20px; + } + + &.mobile { + .kpi-container, + .chart-container { + margin-bottom: 0; + } + } +} diff --git a/app/institutions/dashboard/index/template.hbs b/app/institutions/dashboard/index/template.hbs new file mode 100644 index 00000000000..36145181db9 --- /dev/null +++ b/app/institutions/dashboard/index/template.hbs @@ -0,0 +1,16 @@ + + +
+ +
+
+ +
+
+
diff --git a/app/institutions/dashboard/preprints/controller.ts b/app/institutions/dashboard/preprints/controller.ts new file mode 100644 index 00000000000..c51f5c42de4 --- /dev/null +++ b/app/institutions/dashboard/preprints/controller.ts @@ -0,0 +1,78 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import Intl from 'ember-intl/services/intl'; +import { getSingleOsfmapValue } from 'ember-osf-web/packages/osfmap/jsonld'; + +import { ResourceTypeFilterValue } from 'osf-components/components/search-page/component'; +import { ObjectListColumn } from '../-components/object-list/component'; + +export default class InstitutionDashboardPreprints extends Controller { + @service intl!: Intl; + + missingItemPlaceholder = this.intl.t('institutions.dashboard.object-list.table-items.missing-info'); + + columns: ObjectListColumn[] = [ + { // Title + name: this.intl.t('institutions.dashboard.object-list.table-headers.title'), + type: 'link', + getHref: searchResult => searchResult.indexCard.get('osfIdentifier'), + getLinkText: searchResult => searchResult.displayTitle, + }, + { // Link + name: this.intl.t('institutions.dashboard.object-list.table-headers.link'), + type: 'link', + getHref: searchResult => searchResult.indexCard.get('osfIdentifier'), + getLinkText: searchResult => searchResult.indexCard.get('osfGuid'), + }, + { // Date created + name: this.intl.t('institutions.dashboard.object-list.table-headers.created_date'), + getValue: searchResult => getSingleOsfmapValue(searchResult.resourceMetadata, ['dateCreated']), + sortKey: 'dateCreated', + }, + { // Date modified + name: this.intl.t('institutions.dashboard.object-list.table-headers.modified_date'), + getValue: searchResult => getSingleOsfmapValue(searchResult.resourceMetadata, ['dateModified']), + sortKey: 'dateModified', + }, + { // DOI + name: this.intl.t('institutions.dashboard.object-list.table-headers.doi'), + type: 'doi', + }, + { // License + name: this.intl.t('institutions.dashboard.object-list.table-headers.license'), + getValue: searchResult => searchResult.license?.name || this.missingItemPlaceholder, + }, + { // Contributor name + permissions + name: this.intl.t('institutions.dashboard.object-list.table-headers.contributor_name'), + type: 'contributors', + }, + { // View count + name: this.intl.t('institutions.dashboard.object-list.table-headers.view_count'), + getValue: searchResult => { + const metrics = searchResult.usageMetrics; + return metrics ? metrics.viewCount : this.missingItemPlaceholder; + }, + sortKey: 'usage.viewCount', + sortParam: 'integer-value', + }, + { // Download count + name: this.intl.t('institutions.dashboard.object-list.table-headers.download_count'), + getValue: searchResult => { + const metrics = searchResult.usageMetrics; + return metrics ? metrics.downloadCount : this.missingItemPlaceholder; + }, + sortKey: 'usage.downloadCount', + sortParam: 'integer-value', + }, + ]; + + get defaultQueryOptions() { + const identifiers = this.model.institution.iris.join(','); + return { + cardSearchFilter: { + affiliation: [identifiers], + resourceType: ResourceTypeFilterValue.Preprints, + }, + }; + } +} diff --git a/app/institutions/dashboard/preprints/route.ts b/app/institutions/dashboard/preprints/route.ts new file mode 100644 index 00000000000..b083dbe9831 --- /dev/null +++ b/app/institutions/dashboard/preprints/route.ts @@ -0,0 +1,4 @@ +import Route from '@ember/routing/route'; + +export default class InstitutionsDashboardPreprintsRoute extends Route { +} diff --git a/app/institutions/dashboard/preprints/template.hbs b/app/institutions/dashboard/preprints/template.hbs new file mode 100644 index 00000000000..3a7ffd7838a --- /dev/null +++ b/app/institutions/dashboard/preprints/template.hbs @@ -0,0 +1,6 @@ + diff --git a/app/institutions/dashboard/projects/controller.ts b/app/institutions/dashboard/projects/controller.ts new file mode 100644 index 00000000000..22e27b6d25e --- /dev/null +++ b/app/institutions/dashboard/projects/controller.ts @@ -0,0 +1,100 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import Intl from 'ember-intl/services/intl'; +import { getSingleOsfmapValue } from 'ember-osf-web/packages/osfmap/jsonld'; + +import humanFileSize from 'ember-osf-web/utils/human-file-size'; +import { ObjectListColumn } from '../-components/object-list/component'; + +export default class InstitutionDashboardProjects extends Controller { + @service intl!: Intl; + + missingItemPlaceholder = this.intl.t('institutions.dashboard.object-list.table-items.missing-info'); + + columns: ObjectListColumn[] = [ + { // Title + name: this.intl.t('institutions.dashboard.object-list.table-headers.title'), + type: 'link', + getHref: searchResult => searchResult.indexCard.get('osfIdentifier'), + getLinkText: searchResult => searchResult.displayTitle, + }, + { // Link + name: this.intl.t('institutions.dashboard.object-list.table-headers.link'), + type: 'link', + getHref: searchResult => searchResult.indexCard.get('osfIdentifier'), + getLinkText: searchResult => searchResult.indexCard.get('osfGuid'), + }, + { // Date created + name: this.intl.t('institutions.dashboard.object-list.table-headers.created_date'), + getValue: searchResult => getSingleOsfmapValue(searchResult.resourceMetadata, ['dateCreated']), + sortKey: 'dateCreated', + }, + { // Date modified + name: this.intl.t('institutions.dashboard.object-list.table-headers.modified_date'), + getValue: searchResult => getSingleOsfmapValue(searchResult.resourceMetadata, ['dateModified']), + sortKey: 'dateModified', + }, + { // DOI + name: this.intl.t('institutions.dashboard.object-list.table-headers.doi'), + type: 'doi', + }, + { // Storage location + name: this.intl.t('institutions.dashboard.object-list.table-headers.storage_location'), + getValue: searchResult => searchResult.storageRegion, + }, + { // Total data stored + name: this.intl.t('institutions.dashboard.object-list.table-headers.total_data_stored'), + getValue: searchResult => { + const byteCount = getSingleOsfmapValue(searchResult.resourceMetadata, ['storageByteCount']); + return byteCount ? humanFileSize(byteCount) : this.missingItemPlaceholder; + }, + sortKey: 'storageByteCount', + sortParam: 'integer-value', + }, + { // Contributor name + permissions + name: this.intl.t('institutions.dashboard.object-list.table-headers.contributor_name'), + type: 'contributors', + }, + { // View count + name: this.intl.t('institutions.dashboard.object-list.table-headers.view_count'), + getValue: searchResult => { + const metrics = searchResult.usageMetrics; + return metrics ? metrics.viewCount : this.missingItemPlaceholder; + }, + sortKey: 'usage.viewCount', + sortParam: 'integer-value', + }, + { // Resource type + name: this.intl.t('institutions.dashboard.object-list.table-headers.resource_nature'), + getValue: searchResult => searchResult.resourceNature || this.missingItemPlaceholder, + }, + { // License + name: this.intl.t('institutions.dashboard.object-list.table-headers.license'), + getValue: searchResult => searchResult.license?.name || this.missingItemPlaceholder, + }, + { // addons associated + name: this.intl.t('institutions.dashboard.object-list.table-headers.addons'), + getValue: searchResult => searchResult.configuredAddonNames.length ? searchResult.configuredAddonNames : + this.missingItemPlaceholder, + }, + { // Funder name + name: this.intl.t('institutions.dashboard.object-list.table-headers.funder_name'), + getValue: searchResult => { + if (!searchResult.funders) { + return this.missingItemPlaceholder; + } + return searchResult.funders.map((funder: { name: string }) => funder.name).join(', '); + }, + }, + ]; + + get defaultQueryOptions() { + const identifiers = this.model.institution.iris.join(','); + return { + cardSearchFilter: { + affiliation: [identifiers], + resourceType: 'Project', + }, + }; + } +} diff --git a/app/institutions/dashboard/projects/route.ts b/app/institutions/dashboard/projects/route.ts new file mode 100644 index 00000000000..92028943388 --- /dev/null +++ b/app/institutions/dashboard/projects/route.ts @@ -0,0 +1,4 @@ +import Route from '@ember/routing/route'; + +export default class InstitutionsDashboardRoute extends Route { +} diff --git a/app/institutions/dashboard/projects/template.hbs b/app/institutions/dashboard/projects/template.hbs new file mode 100644 index 00000000000..64a49591bea --- /dev/null +++ b/app/institutions/dashboard/projects/template.hbs @@ -0,0 +1,6 @@ + diff --git a/app/institutions/dashboard/registrations/controller.ts b/app/institutions/dashboard/registrations/controller.ts new file mode 100644 index 00000000000..d991172f29f --- /dev/null +++ b/app/institutions/dashboard/registrations/controller.ts @@ -0,0 +1,98 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import Intl from 'ember-intl/services/intl'; +import { getSingleOsfmapValue } from 'ember-osf-web/packages/osfmap/jsonld'; + +import humanFileSize from 'ember-osf-web/utils/human-file-size'; +import { ObjectListColumn } from '../-components/object-list/component'; + +export default class InstitutionDashboardRegistrations extends Controller { + @service intl!: Intl; + + missingItemPlaceholder = this.intl.t('institutions.dashboard.object-list.table-items.missing-info'); + columns: ObjectListColumn[] = [ + { // Title + name: this.intl.t('institutions.dashboard.object-list.table-headers.title'), + type: 'link', + getHref: searchResult => searchResult.indexCard.get('osfIdentifier'), + getLinkText: searchResult => searchResult.displayTitle, + }, + { // Link + name: this.intl.t('institutions.dashboard.object-list.table-headers.link'), + type: 'link', + getHref: searchResult => searchResult.indexCard.get('osfIdentifier'), + getLinkText: searchResult => searchResult.indexCard.get('osfGuid'), + }, + { // Date created + name: this.intl.t('institutions.dashboard.object-list.table-headers.created_date'), + getValue: searchResult => getSingleOsfmapValue(searchResult.resourceMetadata, ['dateCreated']), + sortKey: 'dateCreated', + }, + { // Date modified + name: this.intl.t('institutions.dashboard.object-list.table-headers.modified_date'), + getValue: searchResult => getSingleOsfmapValue(searchResult.resourceMetadata, ['dateModified']), + sortKey: 'dateModified', + }, + { // DOI + name: this.intl.t('institutions.dashboard.object-list.table-headers.doi'), + type: 'doi', + }, + { // Storage location + name: this.intl.t('institutions.dashboard.object-list.table-headers.storage_location'), + getValue: searchResult => searchResult.storageRegion, + }, + { // Total data stored + name: this.intl.t('institutions.dashboard.object-list.table-headers.total_data_stored'), + getValue: searchResult => { + const byteCount = getSingleOsfmapValue(searchResult.resourceMetadata, ['storageByteCount']); + return byteCount ? humanFileSize(byteCount) : this.missingItemPlaceholder; + }, + sortKey: 'storageByteCount', + sortParam: 'integer-value', + }, + { // Contributor name + permissions + name: this.intl.t('institutions.dashboard.object-list.table-headers.contributor_name'), + type: 'contributors', + }, + { // View count + name: this.intl.t('institutions.dashboard.object-list.table-headers.view_count'), + getValue: searchResult => { + const metrics = searchResult.usageMetrics; + return metrics ? metrics.viewCount : this.missingItemPlaceholder; + }, + sortKey: 'usage.viewCount', + sortParam: 'integer-value', + }, + { // Resource type + name: this.intl.t('institutions.dashboard.object-list.table-headers.resource_nature'), + getValue: searchResult => searchResult.resourceNature || this.missingItemPlaceholder, + }, + { // License + name: this.intl.t('institutions.dashboard.object-list.table-headers.license'), + getValue: searchResult => searchResult.license?.name || this.missingItemPlaceholder, + }, + { // Funder name + name: this.intl.t('institutions.dashboard.object-list.table-headers.funder_name'), + getValue: searchResult => { + if (!searchResult.funders) { + return this.missingItemPlaceholder; + } + return searchResult.funders.map((funder: { name: string }) => funder.name).join(', '); + }, + }, + { // schema + name: this.intl.t('institutions.dashboard.object-list.table-headers.registration_schema'), + getValue: searchResult => searchResult.registrationTemplate, + }, + ]; + + get defaultQueryOptions() { + const identifiers = this.model.institution.iris.join(','); + return { + cardSearchFilter: { + affiliation: [identifiers], + resourceType: 'Registration', + }, + }; + } +} diff --git a/app/institutions/dashboard/registrations/route.ts b/app/institutions/dashboard/registrations/route.ts new file mode 100644 index 00000000000..460700e098e --- /dev/null +++ b/app/institutions/dashboard/registrations/route.ts @@ -0,0 +1,4 @@ +import Route from '@ember/routing/route'; + +export default class InstitutionsDashboardRegistrationsRoute extends Route { +} diff --git a/app/institutions/dashboard/registrations/template.hbs b/app/institutions/dashboard/registrations/template.hbs new file mode 100644 index 00000000000..afbe7f88a6b --- /dev/null +++ b/app/institutions/dashboard/registrations/template.hbs @@ -0,0 +1,6 @@ + diff --git a/app/institutions/dashboard/route.ts b/app/institutions/dashboard/route.ts index e03dc35352d..16ab7b53501 100644 --- a/app/institutions/dashboard/route.ts +++ b/app/institutions/dashboard/route.ts @@ -1,16 +1,14 @@ import Route from '@ember/routing/route'; import RouterService from '@ember/routing/router-service'; import { inject as service } from '@ember/service'; -import { waitFor } from '@ember/test-waiters'; import Store from '@ember-data/store'; -import { task } from 'ember-concurrency'; -import { taskFor } from 'ember-concurrency-ts'; import InstitutionModel from 'ember-osf-web/models/institution'; import InstitutionDepartmentModel from 'ember-osf-web/models/institution-department'; import InstitutionSummaryMetricModel from 'ember-osf-web/models/institution-summary-metric'; import { QueryHasManyResult } from 'ember-osf-web/models/osf-model'; import captureException from 'ember-osf-web/utils/capture-exception'; +import { notFoundURL } from 'ember-osf-web/utils/clean-url'; export interface InstitutionsDashboardModel { institution: InstitutionModel; @@ -21,16 +19,16 @@ export default class InstitutionsDashboardRoute extends Route { @service router!: RouterService; @service store!: Store; - @task - @waitFor - async modelTask(institutionId: string) { + // eslint-disable-next-line camelcase + async model(params: { institution_id: string }) { try { - const institution = await this.store.findRecord('institution', institutionId, { + const institution = await this.store.findRecord('institution', params.institution_id, { adapterOptions: { include: ['summary_metrics'], }, }); const departmentMetrics = await institution.queryHasMany('departmentMetrics'); + const summaryMetrics = await institution.summaryMetrics; const userMetricInfo: QueryHasManyResult = await institution.queryHasMany( 'userMetrics', @@ -45,15 +43,8 @@ export default class InstitutionsDashboardRoute extends Route { }; } catch (error) { captureException(error); - this.transitionTo('not-found', this.router.get('currentURL').slice(1)); + this.transitionTo('not-found', notFoundURL(window.location.pathname)); return undefined; } } - - // eslint-disable-next-line camelcase - model(params: { institution_id: string }) { - return { - taskInstance: taskFor(this.modelTask).perform(params.institution_id), - }; - } } diff --git a/app/institutions/dashboard/styles.scss b/app/institutions/dashboard/styles.scss deleted file mode 100644 index 3ee33c804b3..00000000000 --- a/app/institutions/dashboard/styles.scss +++ /dev/null @@ -1,100 +0,0 @@ -.banner { - padding: 15px 0; - display: flex; - align-items: center; - justify-content: space-between; - - div { - color: #263947; - } -} - -.dashboard-wrapper { - display: flex; -} - -.table-wrapper { - padding-right: 15px; - flex-grow: 1; -} - -.panel-wrapper { - padding-left: 15px; - text-align: right; -} - -.csv-button { - display: inline-block; - width: 40px; - height: 40px; - background: #fff; - padding: 7px 0; - border: 1px solid #ddd; - margin-bottom: 15px; - text-align: center; - - &:active, - &:hover { - background: #15a5eb; - border-color: #15a5eb; - } -} - -.sso-users-connected { - :global(.panel-body) { - h3 { - margin: 0; - font-size: 6vw; - font-weight: bold; - } - } -} - -.projects { - :global(.panel-body) { - h3 { - margin: 0 0 10px; - font-size: 3.75vw; - font-weight: bold; - } - - p { - margin: 0; - font-size: 16px; - } - } -} - -// Extra large devices (large desktops, 1200px and up) -@media (min-width: 1200px) { - .sso-users-connected { - :global(.panel-body) { - h3 { - font-size: 96px; - } - } - } - - .projects { - :global(.panel-body) { - h3 { - font-size: 72px; - } - } - } -} - -@media (max-width: 767px) { - .dashboard-wrapper { - flex-wrap: wrap-reverse; - } - - .panel-wrapper { - padding-left: 0; - width: 100%; - } - - .table-wrapper { - padding-right: 0; - } -} diff --git a/app/institutions/dashboard/template.hbs b/app/institutions/dashboard/template.hbs index fd5e7b6a496..1980fe88290 100644 --- a/app/institutions/dashboard/template.hbs +++ b/app/institutions/dashboard/template.hbs @@ -1,59 +1,2 @@ -{{page-title (t 'institutions.dashboard.title' institutionName=this.institution.unsafeName)}} -
-
- {{this.institution.name}} -
- {{t 'institutions.dashboard.last_update'}} -
-
-
-
- -
-
- {{#if this.csvHref}} - - - - {{/if}} - - {{#if this.summaryMetrics}} -

{{this.summaryMetrics.userCount}}

- {{else}} - {{t 'institutions.dashboard.empty'}} - {{/if}} -
- - - - - - -
-
-
\ No newline at end of file +{{page-title (t 'institutions.dashboard.title' institutionName=this.model.institution.unsafeName)}} +{{outlet}} diff --git a/app/institutions/dashboard/users/styles.scss b/app/institutions/dashboard/users/styles.scss new file mode 100644 index 00000000000..93ca0026725 --- /dev/null +++ b/app/institutions/dashboard/users/styles.scss @@ -0,0 +1,3 @@ +.panel-wrapper { + margin-top: 12px; +} diff --git a/app/institutions/dashboard/users/template.hbs b/app/institutions/dashboard/users/template.hbs new file mode 100644 index 00000000000..9bcdf36be8f --- /dev/null +++ b/app/institutions/dashboard/users/template.hbs @@ -0,0 +1,11 @@ + + +
+ +
+
+
diff --git a/app/models/index-card.ts b/app/models/index-card.ts index 3f64d81888a..15fcd514373 100644 --- a/app/models/index-card.ts +++ b/app/models/index-card.ts @@ -16,6 +16,26 @@ export interface LanguageText { '@value': string; } +export enum OsfmapResourceTypes { + Project = 'Project', + ProjectComponent = 'ProjectComponent', + Registration = 'Registration', + RegistrationComponent = 'RegistrationComponent', + Preprint = 'Preprint', + File = 'File', + Person = 'Person', + Agent = 'Agent', + Organization = 'Organization', + Concept = 'Concept', + ConceptScheme = 'Concept:Scheme', +} + +export enum AttributionRoleIris { + Admin = 'osf:admin-contributor', + Write = 'osf:write-contributor', + Read = 'osf:readonly-contributor', +} + export default class IndexCardModel extends Model { @service intl!: IntlService; @@ -36,7 +56,8 @@ export default class IndexCardModel extends Model { } get osfModelType() { - const types = this.resourceMetadata.resourceType.map( (item: any) => item['@id']); + const types: OsfmapResourceTypes = this.resourceMetadata.resourceType + .map((item: Record<'@id', OsfmapResourceTypes>) => item['@id']); if (types.includes('Project') || types.includes('ProjectComponent')) { return 'node'; } else if (types.includes('Registration') || types.includes('RegistrationComponent')) { @@ -74,7 +95,7 @@ export default class IndexCardModel extends Model { async getOsfModel(options?: object) { const identifier = this.resourceIdentifier; if (identifier && this.osfModelType) { - const guid = this.guidFromIdentifierList(identifier); + const guid = this.osfGuid; if (guid) { const osfModel = await this.store.findRecord(this.osfModelType, guid, options); this.osfModel = osfModel; @@ -82,16 +103,16 @@ export default class IndexCardModel extends Model { } } - guidFromIdentifierList() { - for (const iri of this.resourceIdentifier) { - if (iri && iri.startsWith(osfUrl)) { - const pathSegments = iri.slice(osfUrl.length).split('/').filter(Boolean); - if (pathSegments.length === 1) { - return pathSegments[0]; // one path segment; looks like osf-id - } - } + get osfIdentifier() { + return this.resourceIdentifier.find(iri => iri.startsWith(osfUrl)) || ''; + } + + get osfGuid() { + const pathSegments = this.osfIdentifier.slice(osfUrl.length).split('/').filter(Boolean); + if (pathSegments.length === 1) { + return pathSegments[0]; // one path segment; looks like osf-id } - return null; + return ''; } } diff --git a/app/models/institution-summary-metric.ts b/app/models/institution-summary-metric.ts index ac3675fafe6..9e84ede5b0d 100644 --- a/app/models/institution-summary-metric.ts +++ b/app/models/institution-summary-metric.ts @@ -1,10 +1,23 @@ import { attr } from '@ember-data/model'; +import humanFileSize from 'ember-osf-web/utils/human-file-size'; import OsfModel from './osf-model'; export default class InstitutionSummaryMetricModel extends OsfModel { @attr('number') publicProjectCount!: number; @attr('number') privateProjectCount!: number; @attr('number') userCount!: number; + @attr('number') publicRegistrationCount!: number; + @attr('number') publishedPreprintCount!: number; + @attr('number') embargoedRegistrationCount!: number; + @attr('number') storageByteCount!: number; + @attr('number') publicFileCount!: number; + @attr('number') monthlyLoggedInUserCount!: number; + @attr('number') monthlyActiveUserCount!: number; + + + get convertedStorageCount(): string { + return humanFileSize(parseFloat(this.storageByteCount.toFixed(1))); + } } declare module 'ember-data/types/registries/model' { diff --git a/app/models/institution-user.ts b/app/models/institution-user.ts index 0ce79fde66a..2fb19f778d3 100644 --- a/app/models/institution-user.ts +++ b/app/models/institution-user.ts @@ -1,6 +1,6 @@ import { attr, belongsTo, AsyncBelongsTo } from '@ember-data/model'; - import UserModel from 'ember-osf-web/models/user'; +import humanFileSize from 'ember-osf-web/utils/human-file-size'; import OsfModel from './osf-model'; @@ -9,6 +9,16 @@ export default class InstitutionUserModel extends OsfModel { @attr('fixstring') department?: string; @attr('number') publicProjects!: number; @attr('number') privateProjects!: number; + @attr('number') publicRegistrationCount!: number; + @attr('number') embargoedRegistrationCount!: number; + @attr('number') publishedPreprintCount!: number; + @attr('number') publicFileCount!: number; + @attr('number') storageByteCount!: number; + @attr('number') totalObjectCount!: number; + @attr('string') monthLastLogin!: string; // YYYY-MM + @attr('string') monthLastActive!: string; // YYYY-MM + @attr('string') accountCreationDate!: string; // YYYY-MM + @attr('fixstring') orcidId?: string; @belongsTo('user', { async: true }) user!: AsyncBelongsTo & UserModel; @@ -16,6 +26,10 @@ export default class InstitutionUserModel extends OsfModel { get userGuid() { return (this as InstitutionUserModel).belongsTo('user').id(); } + + get userDataUsage() { + return humanFileSize(this.storageByteCount); + } } declare module 'ember-data/types/registries/model' { diff --git a/app/models/institution.ts b/app/models/institution.ts index 9e2fa60ce7a..9b0432faf02 100644 --- a/app/models/institution.ts +++ b/app/models/institution.ts @@ -29,6 +29,7 @@ export default class InstitutionModel extends OsfModel { @attr('string') authUrl!: string; @attr('object') assets?: Assets; @attr('boolean', { defaultValue: false }) currentUserIsAdmin!: boolean; + @attr('fixstring') linkToExternalReportsArchive?: string; // only serialized when currentUserIsAdmin @attr('date') lastUpdated!: Date; @attr('fixstring') rorIri!: string; // identifier_domain in the admin app diff --git a/app/models/search-result.ts b/app/models/search-result.ts index 5d02d1c9c63..45a41c9c43c 100644 --- a/app/models/search-result.ts +++ b/app/models/search-result.ts @@ -2,6 +2,7 @@ import Model, { attr, belongsTo } from '@ember-data/model'; import { inject as service } from '@ember/service'; import { htmlSafe } from '@ember/template'; import IntlService from 'ember-intl/services/intl'; +import { getOsfmapValues, getSingleOsfmapObject, getSingleOsfmapValue } from 'ember-osf-web/packages/osfmap/jsonld'; import { languageFromLanguageCode } from 'osf-components/components/file-metadata-manager/component'; import IndexCardModel from './index-card'; @@ -20,6 +21,17 @@ export interface TextMatchEvidence { osfmapPropertyPath: string[]; } +export const CardLabelTranslationKeys = { + project: 'osf-components.search-result-card.project', + project_component: 'osf-components.search-result-card.project_component', + registration: 'osf-components.search-result-card.registration', + registration_component: 'osf-components.search-result-card.registration_component', + preprint: 'osf-components.search-result-card.preprint', + file: 'osf-components.search-result-card.file', + user: 'osf-components.search-result-card.user', + unknown: 'osf-components.search-result-card.unknown', +}; + export default class SearchResultModel extends Model { @service intl!: IntlService; @@ -51,22 +63,22 @@ export default class SearchResultModel extends Model { get displayTitle() { if (this.resourceType === 'user') { - return this.resourceMetadata['name'][0]['@value']; + return getSingleOsfmapValue(this.resourceMetadata, ['name']); } else if (this.resourceType === 'file') { - return this.resourceMetadata['fileName'][0]['@value']; + return getSingleOsfmapValue(this.resourceMetadata, ['fileName']); } - return this.resourceMetadata['title']?.[0]['@value']; + return getSingleOsfmapValue(this.resourceMetadata, ['title']); } get fileTitle() { if (this.resourceType === 'file') { - return this.resourceMetadata.title?.[0]['@value']; + return getSingleOsfmapValue(this.resourceMetadata, ['title']); } return null; } get description() { - return this.resourceMetadata.description?.[0]?.['@value']; + return getSingleOsfmapValue(this.resourceMetadata, ['description']); } get absoluteUrl() { @@ -75,19 +87,18 @@ export default class SearchResultModel extends Model { // returns list of affilated institutions for users // returns list of contributors for osf objects - // returns list of affiliated institutions for osf users get affiliatedEntities() { if (this.resourceType === 'user') { if (this.resourceMetadata.affiliation) { return this.resourceMetadata.affiliation.map((item: any) => - ({ name: item.name[0]['@value'], absoluteUrl: item['@id'] })); + ({ name: getSingleOsfmapValue(item, ['name']), absoluteUrl: item['@id'] })); } } else if (this.resourceMetadata.creator) { return this.resourceMetadata.creator?.map((item: any) => - ({ name: item.name[0]['@value'], absoluteUrl: item['@id'] })); + ({ name: getSingleOsfmapValue(item, ['name']), absoluteUrl: item['@id'] })); } else if (this.isContainedBy?.[0]?.creator) { return this.isContainedBy[0].creator.map((item: any) => - ({ name: item.name?.[0]?.['@value'], absoluteUrl: item['@id'] })); + ({ name: getSingleOsfmapValue(item, ['name']), absoluteUrl: item['@id'] })); } } @@ -100,22 +111,22 @@ export default class SearchResultModel extends Model { return [ { label: this.intl.t('osf-components.search-result-card.date_registered'), - date: this.resourceMetadata.dateCreated?.[0]['@value'], + date: getSingleOsfmapValue(this.resourceMetadata, ['dateCreated']), }, { label: this.intl.t('osf-components.search-result-card.date_modified'), - date: this.resourceMetadata.dateModified?.[0]['@value'], + date: getSingleOsfmapValue(this.resourceMetadata, ['dateModified']), }, ]; default: return [ { label: this.intl.t('osf-components.search-result-card.date_created'), - date: this.resourceMetadata.dateCreated?.[0]['@value'], + date: getSingleOsfmapValue(this.resourceMetadata, ['dateCreated']), }, { label: this.intl.t('osf-components.search-result-card.date_modified'), - date: this.resourceMetadata.dateModified?.[0]['@value'], + date: getSingleOsfmapValue(this.resourceMetadata, ['dateModified']), }, ]; } @@ -153,8 +164,8 @@ export default class SearchResultModel extends Model { const isPartOfCollection = this.resourceMetadata.isPartOfCollection; if (isPartOfCollection) { return { - title: this.resourceMetadata.isPartOfCollection?.[0]?.title?.[0]?.['@value'], - absoluteUrl: this.resourceMetadata.isPartOfCollection?.[0]?.['@id'], + title: getSingleOsfmapValue(this.resourceMetadata, ['isPartOfCollection', 'title']), + absoluteUrl: getSingleOsfmapValue(this.resourceMetadata, ['isPartOfCollection']), }; } return null; @@ -162,7 +173,8 @@ export default class SearchResultModel extends Model { get languageFromCode() { if (this.resourceMetadata.language) { - return languageFromLanguageCode(this.resourceMetadata.language[0]['@value']); + const language = getSingleOsfmapValue(this.resourceMetadata, ['language']); + return languageFromLanguageCode(language); } return null; } @@ -170,8 +182,8 @@ export default class SearchResultModel extends Model { get funders() { if (this.resourceMetadata.funder) { return this.resourceMetadata.funder.map( (item: any) => ({ - name: item.name[0]['@value'], - identifier: item.identifier?.[0]['@value'], + name: getSingleOsfmapValue(item, ['name']), + identifier: getSingleOsfmapValue(item, ['identifier']), })); } return null; @@ -180,8 +192,8 @@ export default class SearchResultModel extends Model { get nodeFunders() { if (this.resourceMetadata.isContainedBy?.[0]?.funder) { return this.resourceMetadata.isContainedBy[0].funder.map( (item: any) => ({ - name: item.name[0]['@value'], - identifier: item.identifier?.[0]['@value'], + name: getSingleOsfmapValue(item, ['name']), + identifier: getSingleOsfmapValue(item, ['identifier']), })); } return null; @@ -190,8 +202,8 @@ export default class SearchResultModel extends Model { get provider() { if (this.resourceMetadata.publisher) { return { - name: this.resourceMetadata.publisher[0]?.name?.[0]['@value'], - identifier: this.resourceMetadata.publisher[0]['@id'], + name: getSingleOsfmapValue(this.resourceMetadata, ['publisher', 'name']), + identifier: getSingleOsfmapValue(this.resourceMetadata, ['publisher']), }; } return null; @@ -204,8 +216,8 @@ export default class SearchResultModel extends Model { get license() { if (this.resourceMetadata.rights) { return { - name: this.resourceMetadata.rights?.[0]?.name?.[0]?.['@value'], - identifier: this.resourceMetadata.rights?.[0]?.['@id'], + name: getSingleOsfmapValue(this.resourceMetadata, ['rights', 'name']), + identifier: getSingleOsfmapValue(this.resourceMetadata, ['rights']), }; } return null; @@ -214,9 +226,9 @@ export default class SearchResultModel extends Model { get nodeLicense() { if (this.resourceMetadata.isContainedBy?.[0]?.rights) { return { - name: this.resourceMetadata.isContainedBy[0].rights?.[0]?.name?.[0]?.['@value'], - identifier: this.resourceMetadata.rights?.[0]?.['@id'] || - this.resourceMetadata.isContainedBy[0].rights[0]?.['@id'], + name: getSingleOsfmapValue(this.resourceMetadata, ['isContainedBy', 'rights', 'name']), + identifier: getSingleOsfmapValue(this.resourceMetadata, ['rights']) || + getSingleOsfmapValue(this.resourceMetadata, ['isContainedBy', 'rights']), }; } return null; @@ -242,6 +254,10 @@ export default class SearchResultModel extends Model { return 'unknown'; } + get intlResourceType() { + return this.intl.t(CardLabelTranslationKeys[this.resourceType]); + } + get orcids() { if (this.resourceMetadata.identifier) { const orcids = this.resourceMetadata.identifier.filter( @@ -253,7 +269,7 @@ export default class SearchResultModel extends Model { } get resourceNature() { - return this.resourceMetadata.resourceNature?.[0]?.displayLabel?.[0]?.['@value']; + return getSingleOsfmapValue(this.resourceMetadata, ['resourceNature','displayLabel']); } get hasDataResource() { @@ -277,12 +293,32 @@ export default class SearchResultModel extends Model { } get registrationTemplate() { - return this.resourceMetadata.conformsTo?.[0]?.title?.[0]?.['@value']; + return getSingleOsfmapValue(this.resourceMetadata, ['conformsTo', 'title']); } get isWithdrawn() { return this.resourceMetadata.dateWithdrawn || this.resourceMetadata['https://osf.io/vocab/2022/withdrawal']; } + + get configuredAddonNames() { + return getOsfmapValues(this.resourceMetadata, ['hasOsfAddon', 'prefLabel']); + } + + get storageRegion() { + return getSingleOsfmapValue(this.resourceMetadata, ['storageRegion', 'prefLabel']); + } + + get usageMetrics() { + const usage = getSingleOsfmapObject(this.resourceMetadata, ['usage']); + if (!usage) { + return null; + } + return { + period: getSingleOsfmapValue(usage, ['temporalCoverage']), + viewCount: getSingleOsfmapValue(usage, ['viewCount']), + downloadCount: getSingleOsfmapValue(usage, ['downloadCount']), + }; + } } declare module 'ember-data/types/registries/model' { diff --git a/app/packages/osfmap/jsonld.ts b/app/packages/osfmap/jsonld.ts new file mode 100644 index 00000000000..9711ea177ca --- /dev/null +++ b/app/packages/osfmap/jsonld.ts @@ -0,0 +1,37 @@ +export function *iterOsfmapObjects(osfmapObject: any, propertyPath: string[]): IterableIterator { + const [property, ...remainingPath] = propertyPath; + const innerObjArray = osfmapObject[property] || []; + if (remainingPath.length) { + for (const innerObj of innerObjArray) { + yield* iterOsfmapObjects(innerObj, remainingPath); + } + } else { + yield* innerObjArray; + } +} + +export function *iterOsfmapValues(osfmapObject: any, propertyPath: string[]): IterableIterator { + for (const obj of iterOsfmapObjects(osfmapObject, propertyPath)) { + yield (Object.hasOwn(obj, '@id') ? obj['@id'] : obj['@value']); + } +} + +export function getOsfmapValues(osfmapObject: any, propertyPath: string[]) { + return Array.from(iterOsfmapValues(osfmapObject, propertyPath)); +} + +export function getSingleOsfmapValue(osfmapObject: any, propertyPath: string[]) { + return iterOsfmapValues(osfmapObject, propertyPath).next().value; +} + +export function getOsfmapObjects(osfmapObject: any, propertyPath: string[]) { + return Array.from(iterOsfmapObjects(osfmapObject, propertyPath)); +} + +export function getSingleOsfmapObject(osfmapObject: any, propertyPath: string[]) { + return iterOsfmapObjects(osfmapObject, propertyPath).next().value; +} + +export function hasOsfmapValue(osfmapObject: any, propertyPath: string[], expectedValue: any) { + return Array.from(iterOsfmapValues(osfmapObject, propertyPath)).some(value => value === expectedValue); +} diff --git a/app/router.ts b/app/router.ts index 02a2f8aa6c3..be2416a6e37 100644 --- a/app/router.ts +++ b/app/router.ts @@ -23,7 +23,12 @@ Router.map(function() { this.route('search'); this.route('institutions', function() { this.route('discover', { path: '/:institution_id' }); - this.route('dashboard', { path: '/:institution_id/dashboard' }); + this.route('dashboard', { path: '/:institution_id/dashboard' }, function() { + this.route('projects'); + this.route('registrations'); + this.route('preprints'); + this.route('users'); + }); }); this.route('preprints', function() { diff --git a/app/styles/_components.scss b/app/styles/_components.scss index 7aa123fa52c..0e1db0303ad 100644 --- a/app/styles/_components.scss +++ b/app/styles/_components.scss @@ -902,3 +902,38 @@ button.nav-user-dropdown { .logoutLink { cursor: pointer; } + +@mixin tab-list { + margin-bottom: 10px; + border-bottom: 1px solid $color-border-gray; + box-sizing: border-box; + color: $color-text-black; + display: block; + line-height: 20px; + list-style-image: none; + list-style-position: outside; + list-style-type: none; + height: 41px; + padding: 0; + + li { + display: block; + position: relative; + margin-bottom: -1px; + float: left; + height: 41px; + padding: 10px 15px; + } + + li.ember-tabs__tab--selected { + background-color: $bg-light; + border-bottom: 2px solid $color-blue; + } + + li:hover { + border-color: transparent; + text-decoration: none; + background-color: $bg-light; + color: var(--primary-color); + } +} diff --git a/app/styles/_variables.scss b/app/styles/_variables.scss index adcc9ab19ff..06441451109 100644 --- a/app/styles/_variables.scss +++ b/app/styles/_variables.scss @@ -130,6 +130,7 @@ $color-grey: #333; $color-filter-bg: #a4b3bd; $color-red: #f00; $color-green: #090; +$color-green-light: #90ee90; $color-yellow: #ff0; $color-turquoise: rgb(64, 224, 211); $color-purple: rgb(154, 0, 192); diff --git a/ember-cli-build.js b/ember-cli-build.js index ebbc8fe6086..b6b9e0abb1e 100644 --- a/ember-cli-build.js +++ b/ember-cli-build.js @@ -32,7 +32,9 @@ module.exports = function(defaults) { ], }, 'ember-composable-helpers': { - only: ['compose', 'contains', 'flatten', 'includes', 'range', 'queue', 'map-by', 'without', 'find-by'], + only: [ + 'call', 'compose', 'contains', 'find-by', 'flatten', 'includes', 'map-by', 'queue', 'range', 'without', + ], }, fingerprint: { enabled: true, diff --git a/lib/app-components/addon/components/project-contributors/list/item/template.hbs b/lib/app-components/addon/components/project-contributors/list/item/template.hbs index d7b13fa1c58..4211d83e60b 100644 --- a/lib/app-components/addon/components/project-contributors/list/item/template.hbs +++ b/lib/app-components/addon/components/project-contributors/list/item/template.hbs @@ -48,11 +48,11 @@ @selected={{@contributor.permission}} as |option| > - {{t (concat 'app_components.project_contributors.list.item.permissions.' option)}} + {{t (concat 'general.permissions.' option)}} {{else}}
- {{t (concat 'app_components.project_contributors.list.item.permissions.' @contributor.permission)}} + {{t (concat 'general.permissions.' @contributor.permission)}}
{{/if}} diff --git a/lib/osf-components/addon/components/adjustable-paginator/component.ts b/lib/osf-components/addon/components/adjustable-paginator/component.ts new file mode 100644 index 00000000000..fedf34a6bad --- /dev/null +++ b/lib/osf-components/addon/components/adjustable-paginator/component.ts @@ -0,0 +1,92 @@ +import { classNames, tagName } from '@ember-decorators/component'; +import Component from '@ember/component'; +import { action, computed } from '@ember/object'; +import { gt } from '@ember/object/computed'; +import { layout } from 'ember-osf-web/decorators/component'; +import styles from './styles'; +import template from './template'; + +@layout(template, styles) +@tagName('span') +@classNames('sort-group') +export default class AdjustablePaginator extends Component { + page?: number; + maxPage?: number; + totalCount?: number; + previousPage?: () => unknown; + nextPage?: () => unknown; + selectedPageSize = 10; + + defaultPageSizeOptions = [10, 25, 50, 100]; + + @computed('totalCount', 'defaultPageSizeOptions') + get pageSizeOptions(): number[] { + if (this.totalCount) { + // Filter options smaller or equal to totalCount and include the next higher option + const filteredOptions = this.defaultPageSizeOptions.filter(option => option <= this.totalCount); + + // Find the first option greater than totalCount and include it as well + const nextHigherOption = this.defaultPageSizeOptions.find(option => option > this.totalCount); + + if (nextHigherOption) { + filteredOptions.push(nextHigherOption); // Include the next higher option + } + + return filteredOptions; + } + + return this.defaultPageSizeOptions; + } + + @computed('page', 'maxPage') + get hasNext(): boolean { + return Boolean(this.page && this.maxPage && this.page < this.maxPage); + } + @computed('page') + get prevPage(): number { + return this.page - 1; + } + + @computed('page') + get nextPage2(): number { + return this.page + 1; + } + + @computed('maxPage') + get finalPage(): number { + return this.maxPage + 1; + } + + @gt('page', 1) hasPrev!: boolean; + + @gt('maxPage', 1) hasMultiplePages!: boolean; + + @action + _previous() { + if (this.previousPage) { + this.previousPage(); + } + } + + @action + _next() { + if (this.nextPage) { + this.nextPage(); + } + } + + @action + onPageSizeChange(value: int) { + this.set('pageSize', value); + if (this.doReload) { + this.doReload(); + } + } + + @action + setPage(page: number) { + if (this.doReload) { + this.doReload(page); + } + } +} diff --git a/lib/osf-components/addon/components/adjustable-paginator/styles.scss b/lib/osf-components/addon/components/adjustable-paginator/styles.scss new file mode 100644 index 00000000000..066079586da --- /dev/null +++ b/lib/osf-components/addon/components/adjustable-paginator/styles.scss @@ -0,0 +1,76 @@ +.paginator__control { + display: inline-flex; + align-items: center; + margin: 0; + + &:first-of-type { + padding-left: 0; + } +} + +.paginator__button, +.paginator__select { + background-color: $color-bg-white; + border: $border-light; + color: $color-text-blue-dark; + padding: 7px 16px; + font-size: 14px; + border-radius: $radius; + cursor: pointer; + transition: background-color 0.3s ease, border-color 0.3s ease; + display: inline-flex; + align-items: center; + justify-content: center; + height: 40px; // Setting height to ensure the same for both buttons and select + box-sizing: border-box; // Ensures padding and borders are included in the height + + &:hover { + background-color: $color-bg-gray-light; + border-color: $color-border-gray-dark; + } + + &:disabled { + color: $color-text-blue-dark; + background-color: $bg-light; + cursor: not-allowed; + border-color: $color-border-gray; + } + + &:focus { + outline: none; + border-color: $secondary-blue; + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); + } +} + +.paginator__button--current { + background-color: $color-bg-gray-lighter; + border: 1px solid $color-osf-primary; + color: $color-text-blue-dark; + font-weight: bold; + cursor: default; + display: inline-flex; + align-items: center; + justify-content: center; +} + +/* Special styling for next/previous buttons */ +.paginator__button--prev, +.paginator__button--next { + font-weight: bolder; + padding: 5px 12px; +} + +.paginator__select { + max-width: 160px; + margin-top: -5px; + font-weight: bolder; +} + +.paginator__ellipsis { + color: $color-text-blue-dark; + margin: 0 8px; + font-size: 14px; + display: inline-flex; + align-items: center; +} diff --git a/lib/osf-components/addon/components/adjustable-paginator/template.hbs b/lib/osf-components/addon/components/adjustable-paginator/template.hbs new file mode 100644 index 00000000000..4d04950503e --- /dev/null +++ b/lib/osf-components/addon/components/adjustable-paginator/template.hbs @@ -0,0 +1,140 @@ + +
+ +
+
+ + +{{#if this.hasMultiplePages}} + + + + + {{!-- Always show the first page button --}} + + + + + {{#if (lte this.maxPage 3)}} + {{!-- If fewer than 3 pages, show all pages --}} + {{#each (range 2 3) as |page|}} + + + + {{/each}} + {{else}} + {{#if (gt this.prevPage 2)}} + + + + {{/if}} + + {{!-- Conditionally show previous and current pages --}} + {{#if (not (eq this.prevPage 1))}} + {{#if (not (eq this.page 1))}} + + + + {{/if}} + {{/if}} + + {{#if (not (eq this.page 1))}} + + + + {{/if}} + + {{!-- Show nextPage only if it differs from maxPage --}} + {{#if (and this.hasNext (not (eq this.nextPage2 this.maxPage)))}} + + + + {{/if}} + + {{#if (not (gte this.nextPage2 this.maxPage))}} + {{#if (not (and (eq this.page 3) (eq this.maxPage 5))) }} + + + + {{/if}} + {{/if}} + + {{!-- Always show the maxPage button --}} + {{#if (not (eq this.page this.maxPage))}} + + + + {{/if}} + {{/if}} + + + + +{{/if}} diff --git a/lib/osf-components/addon/components/contributors/add-unregistered-modal/template.hbs b/lib/osf-components/addon/components/contributors/add-unregistered-modal/template.hbs index a64301b3c5c..be16b23125c 100644 --- a/lib/osf-components/addon/components/contributors/add-unregistered-modal/template.hbs +++ b/lib/osf-components/addon/components/contributors/add-unregistered-modal/template.hbs @@ -42,7 +42,7 @@ data-test-select-permission as |permission| > - {{t (concat 'osf-components.contributors.permissions.' permission)}} + {{t (concat 'general.permissions.' permission)}} {{#let (unique-id 'citation-checkbox') as |id|}}