diff --git a/src/app/features/admin-institutions/admin-institutions.component.ts b/src/app/features/admin-institutions/admin-institutions.component.ts index 21c1f8503..e16f0cb72 100644 --- a/src/app/features/admin-institutions/admin-institutions.component.ts +++ b/src/app/features/admin-institutions/admin-institutions.component.ts @@ -8,10 +8,12 @@ import { NgOptimizedImage } from '@angular/common'; import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core'; import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'; -import { resourceTabOptions } from '@osf/features/admin-institutions/constants/resource-tab-option.constant'; import { Primitive } from '@osf/shared/helpers'; +import { FetchInstitutionById, InstitutionsSearchSelectors } from '@osf/shared/stores'; import { LoadingSpinnerComponent, SelectComponent } from '@shared/components'; -import { FetchInstitutionById, InstitutionsSearchSelectors } from '@shared/stores'; + +import { resourceTabOptions } from './constants'; +import { AdminInstitutionResourceTab } from './enums'; @Component({ selector: 'osf-admin-institutions', @@ -31,9 +33,8 @@ export class AdminInstitutionsComponent implements OnInit { fetchInstitution: FetchInstitutionById, }); - selectedTab = 'summary'; - resourceTabOptions = resourceTabOptions; + selectedTab = AdminInstitutionResourceTab.Summary; ngOnInit() { const institutionId = this.route.snapshot.params['institution-id']; @@ -42,16 +43,17 @@ export class AdminInstitutionsComponent implements OnInit { this.actions.fetchInstitution(institutionId); } - this.selectedTab = this.route.snapshot.firstChild?.routeConfig?.path || 'summary'; + this.selectedTab = + (this.route.snapshot.firstChild?.routeConfig?.path as AdminInstitutionResourceTab) || + AdminInstitutionResourceTab.Summary; } onTabChange(selectedValue: Primitive) { - const value = String(selectedValue); + const value = selectedValue as AdminInstitutionResourceTab; this.selectedTab = value; - const selectedTab = this.resourceTabOptions.find((tab) => tab.value === value); - if (selectedTab) { - this.router.navigate([selectedTab.value], { relativeTo: this.route }); + if (this.selectedTab) { + this.router.navigate([this.selectedTab], { relativeTo: this.route }); } } } diff --git a/src/app/features/admin-institutions/components/admin-table/admin-table.component.html b/src/app/features/admin-institutions/components/admin-table/admin-table.component.html index e10abf816..b3a97c3b8 100644 --- a/src/app/features/admin-institutions/components/admin-table/admin-table.component.html +++ b/src/app/features/admin-institutions/components/admin-table/admin-table.component.html @@ -3,9 +3,15 @@ - + - + {{ item.label }} @@ -39,15 +45,13 @@ - @if (downloadLink()) { - - } + @if (reportsLink()) { + + + + {{ 'adminInstitutions.institutionUsers.noData' | translate }} + + @if (isNextPreviousPagination()) { diff --git a/src/app/features/admin-institutions/components/admin-table/admin-table.component.ts b/src/app/features/admin-institutions/components/admin-table/admin-table.component.ts index 90856d99a..6dc0a535f 100644 --- a/src/app/features/admin-institutions/components/admin-table/admin-table.component.ts +++ b/src/app/features/admin-institutions/components/admin-table/admin-table.component.ts @@ -20,9 +20,11 @@ import { } from '@osf/features/admin-institutions/models'; import { CustomPaginatorComponent } from '@osf/shared/components'; import { StopPropagationDirective } from '@shared/directives'; -import { SortOrder } from '@shared/enums'; import { QueryParams } from '@shared/models'; +import { DOWNLOAD_OPTIONS } from '../../constants'; +import { DownloadType } from '../../enums'; + @Component({ selector: 'osf-admin-table', imports: [ @@ -75,34 +77,13 @@ export class AdminTableComponent { sortChanged = output(); iconClicked = output(); linkPageChanged = output(); + downloadClicked = output(); - downloadLink = input(''); - reportsLink = input(''); + reportsLink = input(''); selectedColumns = signal([]); - downloadMenuItems = computed(() => { - const baseUrl = this.downloadLink(); - if (!baseUrl) return []; - - return [ - { - label: 'CSV', - icon: 'fa fa-file-csv', - link: this.createUrl(baseUrl, 'text/csv'), - }, - { - label: 'TSV', - icon: 'fa fa-file-alt', - link: this.createUrl(baseUrl, 'text/tab-separated-values'), - }, - { - label: 'JSON', - icon: 'fa fa-file-code', - link: this.createUrl(baseUrl, 'application/json'), - }, - ]; - }); + downloadMenuItems = DOWNLOAD_OPTIONS; selectedColumnsComputed = computed(() => { const selected = this.selectedColumns(); @@ -149,9 +130,10 @@ export class AdminTableComponent { if (event.field && this.userInitiatedSort) { this.sortChanged.emit({ sortColumn: event.field, - sortOrder: event.order === -1 ? SortOrder.Desc : SortOrder.Asc, + sortOrder: event.order, } as QueryParams); } + this.userInitiatedSort = false; } @@ -180,10 +162,6 @@ export class AdminTableComponent { this.linkPageChanged.emit(link); } - private createUrl(baseUrl: string, mediaType: string): string { - return `${baseUrl}&acceptMediatype=${encodeURIComponent(mediaType)}`; - } - getLinkUrl(value: string | number | TableCellLink | undefined): string { if (this.isLink(value)) { return value.url; diff --git a/src/app/features/admin-institutions/constants/contact-options.constant.ts b/src/app/features/admin-institutions/constants/contact-options.constant.ts new file mode 100644 index 000000000..2429a3a05 --- /dev/null +++ b/src/app/features/admin-institutions/constants/contact-options.constant.ts @@ -0,0 +1,8 @@ +import { SelectOption } from '@shared/models'; + +import { ContactOption } from '../enums'; + +export const CONTACT_OPTIONS: SelectOption[] = [ + { label: 'adminInstitutions.contact.requestAccess', value: ContactOption.RequestAccess }, + { label: 'adminInstitutions.contact.sendMessage', value: ContactOption.SendMessage }, +]; diff --git a/src/app/features/admin-institutions/constants/download-formats.constant.ts b/src/app/features/admin-institutions/constants/download-formats.constant.ts new file mode 100644 index 000000000..341006c1d --- /dev/null +++ b/src/app/features/admin-institutions/constants/download-formats.constant.ts @@ -0,0 +1,7 @@ +import { DownloadType } from '../enums'; + +export const DOWNLOAD_FORMATS: Record = { + [DownloadType.CSV]: 'text/csv', + [DownloadType.TSV]: 'text/tab-separated-values', + [DownloadType.JSON]: 'application/json', +}; diff --git a/src/app/features/admin-institutions/constants/download-options.constant.ts b/src/app/features/admin-institutions/constants/download-options.constant.ts new file mode 100644 index 000000000..a84c7871d --- /dev/null +++ b/src/app/features/admin-institutions/constants/download-options.constant.ts @@ -0,0 +1,19 @@ +import { DownloadType } from '../enums'; + +export const DOWNLOAD_OPTIONS = [ + { + value: DownloadType.CSV, + label: 'CSV', + icon: 'fa fa-file-csv', + }, + { + value: DownloadType.TSV, + label: 'TSV', + icon: 'fa fa-file-alt', + }, + { + value: DownloadType.JSON, + label: 'JSON', + icon: 'fa fa-file-code', + }, +]; diff --git a/src/app/features/admin-institutions/constants/index.ts b/src/app/features/admin-institutions/constants/index.ts index 7c1360d02..18344752f 100644 --- a/src/app/features/admin-institutions/constants/index.ts +++ b/src/app/features/admin-institutions/constants/index.ts @@ -1,6 +1,9 @@ -export * from './admin-table-columns.constant'; +export * from './contact-options.constant'; export * from './department-options.constant'; +export * from './download-formats.constant'; +export * from './download-options.constant'; export * from './preprints-table-columns.constant'; export * from './project-table-columns.constant'; export * from './registration-table-columns.constant'; export * from './resource-tab-option.constant'; +export * from './user-table-columns.constant'; diff --git a/src/app/features/admin-institutions/constants/project-table-columns.constant.ts b/src/app/features/admin-institutions/constants/project-table-columns.constant.ts index 51628fe50..52d16b328 100644 --- a/src/app/features/admin-institutions/constants/project-table-columns.constant.ts +++ b/src/app/features/admin-institutions/constants/project-table-columns.constant.ts @@ -48,6 +48,10 @@ export const projectTableColumns: TableColumn[] = [ sortable: true, isLink: true, linkTarget: '_blank', + showIcon: true, + iconClass: 'fa-solid fa-comment text-primary', + iconTooltip: 'adminInstitutions.institutionUsers.sendMessage', + iconAction: 'sendMessage', }, { field: 'views', diff --git a/src/app/features/admin-institutions/constants/resource-tab-option.constant.ts b/src/app/features/admin-institutions/constants/resource-tab-option.constant.ts index 6e9799ac0..d4d9249b2 100644 --- a/src/app/features/admin-institutions/constants/resource-tab-option.constant.ts +++ b/src/app/features/admin-institutions/constants/resource-tab-option.constant.ts @@ -1,9 +1,11 @@ import { CustomOption } from '@shared/models'; -export const resourceTabOptions: CustomOption[] = [ - { label: 'adminInstitutions.summary.title', value: 'summary' }, - { label: 'common.search.tabs.users', value: 'users' }, - { label: 'common.search.tabs.projects', value: 'projects' }, - { label: 'common.search.tabs.registrations', value: 'registrations' }, - { label: 'common.search.tabs.preprints', value: 'preprints' }, +import { AdminInstitutionResourceTab } from '../enums'; + +export const resourceTabOptions: CustomOption[] = [ + { label: 'adminInstitutions.summary.title', value: AdminInstitutionResourceTab.Summary }, + { label: 'common.search.tabs.users', value: AdminInstitutionResourceTab.Users }, + { label: 'common.search.tabs.projects', value: AdminInstitutionResourceTab.Projects }, + { label: 'common.search.tabs.registrations', value: AdminInstitutionResourceTab.Registrations }, + { label: 'common.search.tabs.preprints', value: AdminInstitutionResourceTab.Preprints }, ]; diff --git a/src/app/features/admin-institutions/constants/admin-table-columns.constant.ts b/src/app/features/admin-institutions/constants/user-table-columns.constant.ts similarity index 100% rename from src/app/features/admin-institutions/constants/admin-table-columns.constant.ts rename to src/app/features/admin-institutions/constants/user-table-columns.constant.ts diff --git a/src/app/features/admin-institutions/dialogs/contact-dialog/contact-dialog.component.html b/src/app/features/admin-institutions/dialogs/contact-dialog/contact-dialog.component.html new file mode 100644 index 000000000..2bfdf4b9d --- /dev/null +++ b/src/app/features/admin-institutions/dialogs/contact-dialog/contact-dialog.component.html @@ -0,0 +1,90 @@ + + + + {{ item.label | translate }} + + + + + @if (isRequestAccess) { + {{ 'adminInstitutions.contact.message' | translate }} + + + + + + {{ 'adminInstitutions.contact.administrative' | translate }} + + + + + + {{ 'adminInstitutions.contact.readWrite' | translate }} + + + } + + + + + + + {{ 'adminInstitutions.institutionUsers.sincerelyYours' | translate }}, + {{ config.data }} + + + @if (!isRequestAccess) { + + + + + {{ 'adminInstitutions.institutionUsers.ccSender' | translate }} + + + + + + + {{ 'adminInstitutions.institutionUsers.allowReplyToSenderAddress' | translate }} + + + + } + + + + + + + diff --git a/src/app/features/admin-institutions/dialogs/contact-dialog/contact-dialog.component.scss b/src/app/features/admin-institutions/dialogs/contact-dialog/contact-dialog.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/admin-institutions/dialogs/contact-dialog/contact-dialog.component.spec.ts b/src/app/features/admin-institutions/dialogs/contact-dialog/contact-dialog.component.spec.ts new file mode 100644 index 000000000..69b882683 --- /dev/null +++ b/src/app/features/admin-institutions/dialogs/contact-dialog/contact-dialog.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ContactDialogComponent } from './contact-dialog.component'; + +describe('ContactDialogComponent', () => { + let component: ContactDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ContactDialogComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ContactDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/admin-institutions/dialogs/contact-dialog/contact-dialog.component.ts b/src/app/features/admin-institutions/dialogs/contact-dialog/contact-dialog.component.ts new file mode 100644 index 000000000..125fa8b49 --- /dev/null +++ b/src/app/features/admin-institutions/dialogs/contact-dialog/contact-dialog.component.ts @@ -0,0 +1,102 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Checkbox } from 'primeng/checkbox'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { RadioButton } from 'primeng/radiobutton'; +import { SelectButton } from 'primeng/selectbutton'; +import { Textarea } from 'primeng/textarea'; + +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms'; + +import { CustomValidators } from '@osf/shared/helpers'; + +import { CONTACT_OPTIONS } from '../../constants'; +import { ContactOption, ProjectPermission } from '../../enums'; +import { ContactDialogData } from '../../models'; + +@Component({ + selector: 'osf-contact-dialog', + imports: [ReactiveFormsModule, FormsModule, SelectButton, RadioButton, Button, Textarea, Checkbox, TranslatePipe], + templateUrl: './contact-dialog.component.html', + styleUrl: './contact-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ContactDialogComponent { + selectedOption = signal(ContactOption.RequestAccess); + + contactOptions = CONTACT_OPTIONS; + projectPermission = ProjectPermission; + + private readonly dialogRef = inject(DynamicDialogRef); + private readonly fb = inject(FormBuilder); + readonly config = inject(DynamicDialogConfig); + + form: FormGroup; + + constructor() { + this.form = this.fb.group({ + permission: [''], + emailContent: [''], + ccSender: [false], + allowReplyToSender: [false], + }); + + this.updateValidation(); + } + + get isRequestAccess(): boolean { + return this.selectedOption() === ContactOption.RequestAccess; + } + + onOptionChange(option: ContactOption): void { + if (this.selectedOption() === option) { + return; + } + + this.selectedOption.set(option); + this.form.reset(); + this.updateValidation(); + } + + onCancel(): void { + this.dialogRef.close(); + } + + onSend(): void { + if (this.form.invalid) { + return; + } + + const formValue = this.form.value; + const data: ContactDialogData = { + emailContent: formValue.emailContent, + selectedOption: this.selectedOption(), + permission: formValue.permission, + ccSender: formValue.ccSender || false, + allowReplyToSender: formValue.allowReplyToSender || false, + ...(this.selectedOption() === ContactOption.RequestAccess && { + selectedAccess: formValue.selectedAccess, + }), + }; + + this.dialogRef.close(data); + } + + private updateValidation(): void { + const emailControl = this.form.get('emailContent')!; + const permissionControl = this.form.get('permission')!; + + if (this.isRequestAccess) { + permissionControl.setValidators(Validators.required); + emailControl.clearValidators(); + } else { + permissionControl.clearValidators(); + emailControl.setValidators([CustomValidators.requiredTrimmed()]); + } + + permissionControl.updateValueAndValidity(); + emailControl.updateValueAndValidity(); + } +} diff --git a/src/app/features/admin-institutions/dialogs/index.ts b/src/app/features/admin-institutions/dialogs/index.ts index fbe1dc741..86e757c45 100644 --- a/src/app/features/admin-institutions/dialogs/index.ts +++ b/src/app/features/admin-institutions/dialogs/index.ts @@ -1 +1,2 @@ +export { ContactDialogComponent } from './contact-dialog/contact-dialog.component'; export { SendEmailDialogComponent } from './send-email-dialog/send-email-dialog.component'; diff --git a/src/app/features/admin-institutions/dialogs/send-email-dialog/send-email-dialog.component.html b/src/app/features/admin-institutions/dialogs/send-email-dialog/send-email-dialog.component.html index 117cdf935..8620c6488 100644 --- a/src/app/features/admin-institutions/dialogs/send-email-dialog/send-email-dialog.component.html +++ b/src/app/features/admin-institutions/dialogs/send-email-dialog/send-email-dialog.component.html @@ -23,9 +23,10 @@ binary="true" inputId="cc-sender" > - {{ - 'adminInstitutions.institutionUsers.ccSender' | translate - }} + + + {{ 'adminInstitutions.institutionUsers.ccSender' | translate }} + @@ -35,9 +36,10 @@ binary="true" inputId="allow-reply" > - {{ - 'adminInstitutions.institutionUsers.allowReplyToSenderAddress' | translate - }} + + + {{ 'adminInstitutions.institutionUsers.allowReplyToSenderAddress' | translate }} + diff --git a/src/app/features/admin-institutions/enums/admin-institution-resource-tab.enum.ts b/src/app/features/admin-institutions/enums/admin-institution-resource-tab.enum.ts new file mode 100644 index 000000000..9304f4f1e --- /dev/null +++ b/src/app/features/admin-institutions/enums/admin-institution-resource-tab.enum.ts @@ -0,0 +1,7 @@ +export enum AdminInstitutionResourceTab { + Summary = 'summary', + Users = 'users', + Projects = 'projects', + Registrations = 'registrations', + Preprints = 'preprints', +} diff --git a/src/app/features/admin-institutions/enums/contact-option.enum.ts b/src/app/features/admin-institutions/enums/contact-option.enum.ts new file mode 100644 index 000000000..1a7285fac --- /dev/null +++ b/src/app/features/admin-institutions/enums/contact-option.enum.ts @@ -0,0 +1,4 @@ +export enum ContactOption { + RequestAccess = 'requestAccess', + SendMessage = 'sendMessage', +} diff --git a/src/app/features/admin-institutions/enums/download-type.enum.ts b/src/app/features/admin-institutions/enums/download-type.enum.ts new file mode 100644 index 000000000..ae4e08fc1 --- /dev/null +++ b/src/app/features/admin-institutions/enums/download-type.enum.ts @@ -0,0 +1,5 @@ +export enum DownloadType { + CSV = 'csv', + TSV = 'tsv', + JSON = 'json', +} diff --git a/src/app/features/admin-institutions/enums/index.ts b/src/app/features/admin-institutions/enums/index.ts new file mode 100644 index 000000000..334c051d9 --- /dev/null +++ b/src/app/features/admin-institutions/enums/index.ts @@ -0,0 +1,5 @@ +export * from './admin-institution-resource-tab.enum'; +export * from './contact-option.enum'; +export * from './download-type.enum'; +export * from './project-permission.enum'; +export * from './search-resource-type.enum'; diff --git a/src/app/features/admin-institutions/enums/project-permission.enum.ts b/src/app/features/admin-institutions/enums/project-permission.enum.ts new file mode 100644 index 000000000..263464aba --- /dev/null +++ b/src/app/features/admin-institutions/enums/project-permission.enum.ts @@ -0,0 +1,4 @@ +export enum ProjectPermission { + Administrative = 'admin', + Write = 'write', +} diff --git a/src/app/features/admin-institutions/enums/search-resource-type.enum.ts b/src/app/features/admin-institutions/enums/search-resource-type.enum.ts new file mode 100644 index 000000000..8c2963ad4 --- /dev/null +++ b/src/app/features/admin-institutions/enums/search-resource-type.enum.ts @@ -0,0 +1,5 @@ +export enum SearchResourceType { + Project = 'Project', + Registration = 'Registration', + Preprint = 'Preprint', +} diff --git a/src/app/features/admin-institutions/helpers/camel-to-snake.helper.ts b/src/app/features/admin-institutions/helpers/camel-to-snake.helper.ts new file mode 100644 index 000000000..48115b4d2 --- /dev/null +++ b/src/app/features/admin-institutions/helpers/camel-to-snake.helper.ts @@ -0,0 +1,8 @@ +export function camelToSnakeCase(str: string): string { + const isSnakeCase = /^[a-z0-9_]+$/.test(str); + if (isSnakeCase) { + return str; + } + + return str.replace(/([A-Z])/g, (letter) => `_${letter.toLowerCase()}`).replace(/^_/, ''); +} diff --git a/src/app/features/admin-institutions/helpers/download-url.helper.ts b/src/app/features/admin-institutions/helpers/download-url.helper.ts new file mode 100644 index 000000000..1ea3ebf56 --- /dev/null +++ b/src/app/features/admin-institutions/helpers/download-url.helper.ts @@ -0,0 +1,19 @@ +import { DOWNLOAD_FORMATS } from '../constants'; +import { DownloadType } from '../enums'; + +export function downloadResults(downloadUrl: string | null, type: DownloadType) { + if (!downloadUrl) { + return; + } + + const cardSearchUrl = new URL(downloadUrl as string); + const format = DOWNLOAD_FORMATS[type]; + + cardSearchUrl.searchParams.set('page[size]', '10000'); + cardSearchUrl.searchParams.set('page[cursor]', ''); + cardSearchUrl.searchParams.set('acceptMediatype', format); + cardSearchUrl.searchParams.set('withFileName', `projects-search-results`); + + const downloadLink = cardSearchUrl.toString(); + window.open(downloadLink, '_blank'); +} diff --git a/src/app/features/admin-institutions/helpers/extract-path-after-domain.ts b/src/app/features/admin-institutions/helpers/extract-path-after-domain.helper.ts similarity index 100% rename from src/app/features/admin-institutions/helpers/extract-path-after-domain.ts rename to src/app/features/admin-institutions/helpers/extract-path-after-domain.helper.ts diff --git a/src/app/features/admin-institutions/helpers/index.ts b/src/app/features/admin-institutions/helpers/index.ts index 779fca02f..45eb44ccd 100644 --- a/src/app/features/admin-institutions/helpers/index.ts +++ b/src/app/features/admin-institutions/helpers/index.ts @@ -1 +1,2 @@ -export * from './extract-path-after-domain'; +export * from './download-url.helper'; +export * from './extract-path-after-domain.helper'; diff --git a/src/app/features/admin-institutions/mappers/index.ts b/src/app/features/admin-institutions/mappers/index.ts index ea84875b8..3480f9b3b 100644 --- a/src/app/features/admin-institutions/mappers/index.ts +++ b/src/app/features/admin-institutions/mappers/index.ts @@ -1,5 +1,6 @@ export { mapInstitutionDepartment, mapInstitutionDepartments } from './institution-departments.mapper'; export { mapPreprintToTableData } from './institution-preprint-to-table-data.mapper'; +export { mapInstitutionPreprints } from './institution-preprints.mapper'; export { mapProjectToTableCellData } from './institution-project-to-table-data.mapper'; export { mapInstitutionProjects } from './institution-projects.mapper'; export { mapRegistrationToTableData } from './institution-registration-to-table-data.mapper'; diff --git a/src/app/features/admin-institutions/mappers/institution-project-to-table-data.mapper.ts b/src/app/features/admin-institutions/mappers/institution-project-to-table-data.mapper.ts index 22411579c..1465bac89 100644 --- a/src/app/features/admin-institutions/mappers/institution-project-to-table-data.mapper.ts +++ b/src/app/features/admin-institutions/mappers/institution-project-to-table-data.mapper.ts @@ -16,8 +16,8 @@ export function mapProjectToTableCellData(project: InstitutionProject): TableCel storageLocation: project.storageRegion || '-', totalDataStored: project.storageByteCount ? `${(project.storageByteCount / (1024 * 1024)).toFixed(1)} MB` : '0 B', creator: { - url: project.creator || '#', - text: project.creator || '-', + url: project.creator.id || '#', + text: project.creator.name || '-', } as TableCellLink, views: project.viewCount?.toString() || '-', resourceType: project.resourceType, diff --git a/src/app/features/admin-institutions/mappers/institution-projects.mapper.ts b/src/app/features/admin-institutions/mappers/institution-projects.mapper.ts index 8b108d6d1..78ec52689 100644 --- a/src/app/features/admin-institutions/mappers/institution-projects.mapper.ts +++ b/src/app/features/admin-institutions/mappers/institution-projects.mapper.ts @@ -20,8 +20,10 @@ export function mapInstitutionProjects(response: InstitutionRegistrationsJsonApi searchResults.forEach((result: SearchResult) => { const indexCardId = result.relationships?.indexCard?.data?.id; + if (indexCardId) { const indexCard = indexCards.find((card: IndexCard) => card.id === indexCardId); + if (indexCard && indexCard.attributes) { const metadata = indexCard.attributes.resourceMetadata; @@ -29,7 +31,10 @@ export function mapInstitutionProjects(response: InstitutionRegistrationsJsonApi projects.push({ id: metadata['@id'] || indexCard.id, title: metadata.title?.[0]?.['@value'] || '', - creator: metadata.creator?.[0]?.name?.[0]?.['@value'] || '', + creator: { + id: metadata.creator?.[0]?.['@id'] || '', + name: metadata.creator?.[0]?.name?.[0]?.['@value'] || '', + }, dateCreated: metadata.dateCreated?.[0]?.['@value'] || '', dateModified: metadata.dateModified?.[0]?.['@value'] || '', resourceType: metadata.resourceType?.[0]?.['@id'] || '', diff --git a/src/app/features/admin-institutions/mappers/request-access.mapper.ts b/src/app/features/admin-institutions/mappers/request-access.mapper.ts new file mode 100644 index 000000000..679991eb7 --- /dev/null +++ b/src/app/features/admin-institutions/mappers/request-access.mapper.ts @@ -0,0 +1,30 @@ +import { RequestProjectAccessData } from '../models'; + +export function requestProjectAccessMapper(request: RequestProjectAccessData) { + return { + data: { + attributes: { + comment: request.messageText, + requested_permissions: request.permission, + request_type: 'institutional_request', + bcc_sender: request.bccSender, + reply_to: request.replyTo, + }, + relationships: { + institution: { + data: { + type: 'institutions', + id: request.institutionId, + }, + }, + message_recipient: { + data: { + type: 'users', + id: request.userId, + }, + }, + }, + type: 'node_requests', + }, + }; +} diff --git a/src/app/features/admin-institutions/models/admin-institution-search-result.model.ts b/src/app/features/admin-institutions/models/admin-institution-search-result.model.ts new file mode 100644 index 000000000..85c07deca --- /dev/null +++ b/src/app/features/admin-institutions/models/admin-institution-search-result.model.ts @@ -0,0 +1,12 @@ +import { PaginationLinksModel } from '@osf/shared/models/pagination-links.model'; + +import { InstitutionPreprint } from './institution-preprint.model'; +import { InstitutionProject } from './institution-project.model'; +import { InstitutionRegistration } from './institution-registration.model'; + +export interface AdminInstitutionSearchResult { + items: InstitutionProject[] | InstitutionRegistration[] | InstitutionPreprint[]; + totalCount: number; + links?: PaginationLinksModel; + downloadLink: string | null; +} diff --git a/src/app/features/admin-institutions/models/contact-dialog-data.model.ts b/src/app/features/admin-institutions/models/contact-dialog-data.model.ts new file mode 100644 index 000000000..044dae333 --- /dev/null +++ b/src/app/features/admin-institutions/models/contact-dialog-data.model.ts @@ -0,0 +1,9 @@ +import { ContactOption } from '../enums'; + +export interface ContactDialogData { + emailContent: string; + selectedOption: ContactOption; + permission?: string; + ccSender: boolean; + allowReplyToSender: boolean; +} diff --git a/src/app/features/admin-institutions/models/index.ts b/src/app/features/admin-institutions/models/index.ts index b7ddff2c9..c7c9432bb 100644 --- a/src/app/features/admin-institutions/models/index.ts +++ b/src/app/features/admin-institutions/models/index.ts @@ -1,3 +1,5 @@ +export * from './admin-institution-search-result.model'; +export * from './contact-dialog-data.model'; export * from './index-search-query-params.model'; export * from './institution-department.model'; export * from './institution-departments-json-api.model'; @@ -16,6 +18,7 @@ export * from './institution-summary-metrics-json-api.model'; export * from './institution-user.model'; export * from './institution-users-json-api.model'; export * from './institution-users-query-params.model'; +export * from './request-project-access.model'; export * from './send-email-dialog-data.model'; export * from './send-message-json-api.model'; export * from './send-message-request.model'; diff --git a/src/app/features/admin-institutions/models/institution-project.model.ts b/src/app/features/admin-institutions/models/institution-project.model.ts index d07d4eeaa..e910c6fd8 100644 --- a/src/app/features/admin-institutions/models/institution-project.model.ts +++ b/src/app/features/admin-institutions/models/institution-project.model.ts @@ -1,7 +1,9 @@ +import { IdName } from '@osf/shared/models'; + export interface InstitutionProject { id: string; title: string; - creator: string; + creator: IdName; dateCreated: string; dateModified: string; resourceType: string; diff --git a/src/app/features/admin-institutions/models/institution-projects-json-api.model.ts b/src/app/features/admin-institutions/models/institution-projects-json-api.model.ts index 0528b8d9a..1d59ecc3b 100644 --- a/src/app/features/admin-institutions/models/institution-projects-json-api.model.ts +++ b/src/app/features/admin-institutions/models/institution-projects-json-api.model.ts @@ -27,7 +27,7 @@ export interface IndexCard extends IncludedItem { export interface ResourceMetadata { '@id'?: string; title?: { '@value': string }[]; - creator?: { name?: { '@value': string }[] }[]; + creator?: { '@id': string; name?: { '@value': string }[] }[]; dateCreated?: { '@value': string }[]; dateModified?: { '@value': string }[]; resourceType?: { '@id': string }[]; diff --git a/src/app/features/admin-institutions/models/request-project-access.model.ts b/src/app/features/admin-institutions/models/request-project-access.model.ts new file mode 100644 index 000000000..473323c86 --- /dev/null +++ b/src/app/features/admin-institutions/models/request-project-access.model.ts @@ -0,0 +1,9 @@ +export interface RequestProjectAccessData { + userId: string; + projectId: string; + institutionId: string; + permission: string; + messageText: string; + bccSender: boolean; + replyTo: boolean; +} diff --git a/src/app/features/admin-institutions/pages/institutions-preprints/institutions-preprints.component.html b/src/app/features/admin-institutions/pages/institutions-preprints/institutions-preprints.component.html index ddf31efc3..c914d18fc 100644 --- a/src/app/features/admin-institutions/pages/institutions-preprints/institutions-preprints.component.html +++ b/src/app/features/admin-institutions/pages/institutions-preprints/institutions-preprints.component.html @@ -2,16 +2,16 @@ -} @else if (tableData().length > 0) { +} @else { @@ -19,8 +19,4 @@ -} @else { - - {{ 'adminInstitutions.preprints.noData' | translate }} - } diff --git a/src/app/features/admin-institutions/pages/institutions-preprints/institutions-preprints.component.ts b/src/app/features/admin-institutions/pages/institutions-preprints/institutions-preprints.component.ts index 7ca721d99..8ae90eebd 100644 --- a/src/app/features/admin-institutions/pages/institutions-preprints/institutions-preprints.component.ts +++ b/src/app/features/admin-institutions/pages/institutions-preprints/institutions-preprints.component.ts @@ -3,29 +3,22 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, untracked } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, Params, Router } from '@angular/router'; - -import { AdminTableComponent } from '@osf/features/admin-institutions/components'; -import { preprintsTableColumns } from '@osf/features/admin-institutions/constants'; -import { mapPreprintToTableData } from '@osf/features/admin-institutions/mappers'; -import { - IndexSearchQueryParamsModel, - InstitutionProjectsQueryParamsModel, - TableCellData, -} from '@osf/features/admin-institutions/models'; -import { InstitutionsAdminSelectors } from '@osf/features/admin-institutions/store'; -import { LoadingSpinnerComponent } from '@osf/shared/components'; -import { TABLE_PARAMS } from '@shared/constants'; -import { SortOrder } from '@shared/enums'; -import { parseQueryFilterParams } from '@shared/helpers'; -import { Institution, QueryParams } from '@shared/models'; -import { InstitutionsSearchSelectors } from '@shared/stores'; - -import { FetchPreprints } from '../../store/institutions-admin.actions'; +import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; -import { environment } from 'src/environments/environment'; +import { LoadingSpinnerComponent } from '@osf/shared/components'; +import { TABLE_PARAMS } from '@osf/shared/constants'; +import { SortOrder } from '@osf/shared/enums'; +import { Institution, QueryParams } from '@osf/shared/models'; +import { InstitutionsSearchSelectors } from '@osf/shared/stores'; + +import { AdminTableComponent } from '../../components'; +import { preprintsTableColumns } from '../../constants'; +import { DownloadType } from '../../enums'; +import { downloadResults } from '../../helpers'; +import { mapPreprintToTableData } from '../../mappers'; +import { TableCellData } from '../../models'; +import { FetchPreprints, InstitutionsAdminSelectors } from '../../store'; @Component({ selector: 'osf-institutions-preprints', @@ -34,13 +27,11 @@ import { environment } from 'src/environments/environment'; styleUrl: './institutions-preprints.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class InstitutionsPreprintsComponent { +export class InstitutionsPreprintsComponent implements OnInit { private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); - private readonly actions = createDispatchMap({ - fetchPreprints: FetchPreprints, - }); + private readonly actions = createDispatchMap({ fetchPreprints: FetchPreprints }); private institutionId = ''; @@ -49,11 +40,10 @@ export class InstitutionsPreprintsComponent { totalCount = select(InstitutionsAdminSelectors.getPreprintsTotalCount); isLoading = select(InstitutionsAdminSelectors.getPreprintsLoading); preprintsLinks = select(InstitutionsAdminSelectors.getPreprintsLinks); + preprintsDownloadLink = select(InstitutionsAdminSelectors.getPreprintsDownloadLink); tableColumns = signal(preprintsTableColumns); - reportsLink = 'https://drive.google.com/drive/folders/1_aFmeJwLp5xBS3-8clZ4xA9L3UFxdzDd'; - queryParams = toSignal(this.route.queryParams); currentPageSize = signal(TABLE_PARAMS.rows); currentSort = signal('-dateModified'); sortField = signal('-dateModified'); @@ -61,121 +51,53 @@ export class InstitutionsPreprintsComponent { currentCursor = signal(''); - tableData = computed(() => { - const preprintsData = this.preprints(); - return preprintsData.map(mapPreprintToTableData) as TableCellData[]; - }); - - downloadLink = computed(() => { - const institution = this.institution(); - const queryParams = this.queryParams(); - - if (!institution?.iris?.length) { - return ''; - } - - const institutionIris = institution.iris.join(','); - const baseUrl = `${environment.shareDomainUrl}/index-card-search`; - let params = new URLSearchParams(); - if (queryParams) { - params = new URLSearchParams({ - 'cardSearchFilter[affiliation][]': institutionIris, - 'cardSearchFilter[resourceType]': 'Preprint', - 'cardSearchFilter[accessService]': environment.webUrl, - 'page[size]': String(queryParams['size'] || this.currentPageSize()), - sort: queryParams['sort'] || this.currentSort(), - }); - } - - if (queryParams && queryParams['cursor']) { - params.append('page[cursor]', queryParams['cursor']); - } - - return `${baseUrl}?${params.toString()}`; - }); - - constructor() { - this.setupQueryParamsEffect(); + tableData = computed(() => this.preprints().map(mapPreprintToTableData) as TableCellData[]); + + ngOnInit(): void { + this.getPreprints(); } onSortChange(params: QueryParams): void { - this.updateQueryParams({ - sort: - params.sortColumn && params.sortOrder - ? params.sortOrder === SortOrder.Desc - ? `-${params.sortColumn}` - : params.sortColumn - : undefined, - }); + this.sortField.set(params.sortColumn || '-dateModified'); + this.sortOrder.set(params.sortOrder || 1); + + const sortField = params.sortColumn || '-dateModified'; + const sortOrder = params.sortOrder || 1; + const sortParam = sortOrder === SortOrder.Desc ? `-${sortField}` : sortField; + + const institution = this.institution() as Institution; + const institutionIris = institution.iris || []; + + this.actions.fetchPreprints(this.institutionId, institutionIris, this.currentPageSize(), sortParam, ''); } onLinkPageChange(link: string): void { const url = new URL(link); const cursor = url.searchParams.get('page[cursor]') || ''; - this.updateQueryParams({ cursor }); - } - - private setupQueryParamsEffect(): void { - effect(() => { - const institutionId = this.route.parent?.snapshot.params['institution-id']; - const rawQueryParams = this.queryParams(); - if (!rawQueryParams && !institutionId) return; - - this.institutionId = institutionId; - const parsedQueryParams = this.parseQueryParams(rawQueryParams as Params); - this.updateComponentState(parsedQueryParams); + const sortField = this.sortField(); + const sortOrder = this.sortOrder(); + const sortParam = sortOrder === -1 ? `-${sortField}` : sortField; - const sortField = parsedQueryParams.sortColumn; - const sortOrder = parsedQueryParams.sortOrder; - const sortParam = sortOrder === SortOrder.Desc ? `-${sortField}` : sortField; - const cursor = parsedQueryParams.cursor; - const size = parsedQueryParams.size; + const institution = this.institution() as Institution; + const institutionIris = institution.iris || []; - const institution = this.institution() as Institution; - const institutionIris = institution.iris || []; - - this.actions.fetchPreprints(this.institutionId, institutionIris, size, sortParam, cursor); - }); + this.actions.fetchPreprints(this.institutionId, institutionIris, this.currentPageSize(), sortParam, cursor); } - private parseQueryParams(params: Params): InstitutionProjectsQueryParamsModel { - const parsed = parseQueryFilterParams(params); - return { - ...parsed, - cursor: params['cursor'] || '', - }; + download(type: DownloadType) { + downloadResults(this.preprintsDownloadLink(), type); } - private updateComponentState(params: InstitutionProjectsQueryParamsModel): void { - untracked(() => { - this.currentPageSize.set(params.size); + private getPreprints(): void { + const institutionId = this.route.parent?.snapshot.params['institution-id']; + if (!institutionId) return; - if (params.sortColumn) { - this.sortField.set(params.sortColumn); - const order = params.sortOrder === SortOrder.Desc ? -1 : 1; - this.sortOrder.set(order); - } - }); - } + this.institutionId = institutionId; + + const institution = this.institution() as Institution; + const institutionIris = institution.iris || []; - private updateQueryParams(params: IndexSearchQueryParamsModel): void { - const queryParams: Record = {}; - - if (params.sort) { - queryParams['sort'] = params.sort; - } - if (params.cursor) { - queryParams['cursor'] = params.cursor; - } - if (params.size) { - queryParams['size'] = params.size.toString(); - } - - this.router.navigate([], { - relativeTo: this.route, - queryParams, - queryParamsHandling: 'merge', - }); + this.actions.fetchPreprints(this.institutionId, institutionIris, this.currentPageSize(), this.sortField(), ''); } } diff --git a/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.html b/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.html index 85b3ad42c..d51a97e2d 100644 --- a/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.html +++ b/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.html @@ -2,7 +2,7 @@ -} @else if (tableData().length > 0) { +} @else { {{ totalCount() }} {{ 'adminInstitutions.projects.totalProjects' | translate }} -} @else { - - {{ 'adminInstitutions.projects.noData' | translate }} - } diff --git a/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.ts b/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.ts index e5226e31c..ec919ce76 100644 --- a/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.ts +++ b/src/app/features/admin-institutions/pages/institutions-projects/institutions-projects.component.ts @@ -1,26 +1,31 @@ import { createDispatchMap, select } from '@ngxs/store'; -import { TranslatePipe } from '@ngx-translate/core'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, untracked } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, Params, Router } from '@angular/router'; +import { DialogService } from 'primeng/dynamicdialog'; -import { AdminTableComponent } from '@osf/features/admin-institutions/components'; -import { projectTableColumns } from '@osf/features/admin-institutions/constants'; -import { mapProjectToTableCellData } from '@osf/features/admin-institutions/mappers'; -import { FetchProjects } from '@osf/features/admin-institutions/store/institutions-admin.actions'; -import { InstitutionsAdminSelectors } from '@osf/features/admin-institutions/store/institutions-admin.selectors'; -import { LoadingSpinnerComponent } from '@osf/shared/components'; -import { TABLE_PARAMS } from '@shared/constants'; -import { SortOrder } from '@shared/enums'; -import { parseQueryFilterParams } from '@shared/helpers'; -import { Institution, QueryParams } from '@shared/models'; -import { InstitutionsSearchSelectors } from '@shared/stores'; +import { filter } from 'rxjs'; -import { InstitutionProject, InstitutionProjectsQueryParamsModel, TableCellData } from '../../models'; +import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ActivatedRoute } from '@angular/router'; -import { environment } from 'src/environments/environment'; +import { UserSelectors } from '@osf/core/store/user'; +import { LoadingSpinnerComponent } from '@osf/shared/components'; +import { TABLE_PARAMS } from '@osf/shared/constants'; +import { SortOrder } from '@osf/shared/enums'; +import { Institution, QueryParams } from '@osf/shared/models'; +import { ToastService } from '@osf/shared/services'; +import { InstitutionsSearchSelectors } from '@osf/shared/stores'; + +import { AdminTableComponent } from '../../components'; +import { projectTableColumns } from '../../constants'; +import { ContactDialogComponent } from '../../dialogs'; +import { ContactOption, DownloadType } from '../../enums'; +import { downloadResults } from '../../helpers'; +import { mapProjectToTableCellData } from '../../mappers'; +import { ContactDialogData, InstitutionProject, TableCellData, TableCellLink, TableIconClickEvent } from '../../models'; +import { FetchProjects, InstitutionsAdminSelectors, RequestProjectAccess, SendUserMessage } from '../../store'; @Component({ selector: 'osf-institutions-projects', @@ -28,19 +33,23 @@ import { environment } from 'src/environments/environment'; templateUrl: './institutions-projects.component.html', styleUrl: './institutions-projects.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, + providers: [DialogService], }) -export class InstitutionsProjectsComponent { +export class InstitutionsProjectsComponent implements OnInit { private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); + private readonly dialogService = inject(DialogService); + private readonly destroyRef = inject(DestroyRef); + private readonly toastService = inject(ToastService); + private readonly translate = inject(TranslateService); private readonly actions = createDispatchMap({ fetchProjects: FetchProjects, + sendUserMessage: SendUserMessage, + requestProjectAccess: RequestProjectAccess, }); institutionId = ''; - reportsLink = 'https://drive.google.com/drive/folders/1_aFmeJwLp5xBS3-8clZ4xA9L3UFxdzDd'; - queryParams = toSignal(this.route.queryParams); currentPageSize = signal(TABLE_PARAMS.rows); first = signal(0); @@ -53,46 +62,32 @@ export class InstitutionsProjectsComponent { totalCount = select(InstitutionsAdminSelectors.getProjectsTotalCount); isLoading = select(InstitutionsAdminSelectors.getProjectsLoading); projectsLinks = select(InstitutionsAdminSelectors.getProjectsLinks); + projectsDownloadLink = select(InstitutionsAdminSelectors.getProjectsDownloadLink); institution = select(InstitutionsSearchSelectors.getInstitution); + currentUser = select(UserSelectors.getCurrentUser); - tableData = computed(() => { - return this.projects().map((project: InstitutionProject): TableCellData => mapProjectToTableCellData(project)); - }); + tableData = computed(() => + this.projects().map((project: InstitutionProject): TableCellData => mapProjectToTableCellData(project)) + ); - downloadUrl = computed(() => { - const baseUrl = `${environment.shareDomainUrl}/index-card-search`; - const institution = this.institution() as Institution; - const institutionIris = institution.iris || []; - const affiliationParam = institutionIris.join(','); - - const params = new URLSearchParams({ - 'cardSearchFilter[affiliation][]': affiliationParam, - 'cardSearchFilter[resourceType]': 'Project', - 'cardSearchFilter[accessService]': environment.webUrl, - 'page[cursor]': '', - 'page[size]': '10000', - sort: this.sortField(), - withFileName: 'projects-search-results', - 'fields[Project]': - 'title,dateCreated,dateModified,sameAs,storageRegion.prefLabel,storageByteCount,creator.name,usage.viewCount,resourceNature.displayLabel,rights.name,hasOsfAddon.prefLabel,funder.name', - }); - - return `${baseUrl}?${params.toString()}`; - }); - - constructor() { - this.setupQueryParamsEffect(); + ngOnInit(): void { + this.getProjects(); } onSortChange(params: QueryParams): void { + console.log(params); + this.sortField.set(params.sortColumn || '-dateModified'); this.sortOrder.set(params.sortOrder || 1); - this.updateQueryParams({ - sortColumn: params.sortColumn || '-dateModified', - sortOrder: params.sortOrder || 1, - cursor: '', - }); + const sortField = params.sortColumn || '-dateModified'; + const sortOrder = params.sortOrder || 1; + const sortParam = sortOrder === SortOrder.Desc ? `-${sortField}` : sortField; + + const institution = this.institution() as Institution; + const institutionIris = institution.iris || []; + + this.actions.fetchProjects(this.institutionId, institutionIris, this.currentPageSize(), sortParam, ''); } onLinkPageChange(linkUrl: string): void { @@ -100,90 +95,85 @@ export class InstitutionsProjectsComponent { const cursor = this.extractCursorFromUrl(linkUrl); - this.updateQueryParams({ - cursor: cursor, - }); - } - - private setupQueryParamsEffect(): void { - effect(() => { - const institutionId = this.route.parent?.snapshot.params['institution-id']; - const rawQueryParams = this.queryParams(); - if (!rawQueryParams && !institutionId) return; - - this.institutionId = institutionId; - const parsedQueryParams = this.parseQueryParams(rawQueryParams as Params); - - this.updateComponentState(parsedQueryParams); - - const sortField = parsedQueryParams.sortColumn; - const sortOrder = parsedQueryParams.sortOrder; - const sortParam = sortOrder === SortOrder.Desc ? `-${sortField}` : sortField; - const cursor = parsedQueryParams.cursor; - const size = parsedQueryParams.size; + const sortField = this.sortField(); + const sortOrder = this.sortOrder(); + const sortParam = sortOrder === -1 ? `-${sortField}` : sortField; - const institution = this.institution() as Institution; - const institutionIris = institution.iris || []; + const institution = this.institution() as Institution; + const institutionIris = institution.iris || []; - this.actions.fetchProjects(this.institutionId, institutionIris, size, sortParam, cursor); - }); + this.actions.fetchProjects(this.institutionId, institutionIris, this.currentPageSize(), sortParam, cursor); } - private updateQueryParams(updates: Partial): void { - const queryParams: Record = {}; - const current = this.route.snapshot.queryParams; - - const same = - (updates.page?.toString() ?? current['page']) === current['page'] && - (updates.size?.toString() ?? current['size']) === current['size'] && - (updates.sortColumn ?? current['sortColumn']) === current['sortColumn'] && - (updates.sortOrder?.toString() ?? current['sortOrder']) === current['sortOrder'] && - (updates.cursor ?? current['cursor']) === current['cursor']; - - if (same) return; + download(type: DownloadType) { + downloadResults(this.projectsDownloadLink(), type); + } - if ('page' in updates) { - queryParams['page'] = updates.page!.toString(); - } - if ('size' in updates) { - queryParams['size'] = updates.size!.toString(); - } - if ('sortColumn' in updates) { - queryParams['sortColumn'] = updates.sortColumn || undefined; - } - if ('sortOrder' in updates) { - queryParams['sortOrder'] = updates.sortOrder?.toString() || undefined; - } - if ('cursor' in updates) { - queryParams['cursor'] = updates.cursor || undefined; + onIconClick(event: TableIconClickEvent): void { + switch (event.action) { + case 'sendMessage': { + this.dialogService + .open(ContactDialogComponent, { + width: '448px', + focusOnShow: false, + header: this.translate.instant('adminInstitutions.institutionUsers.sendEmail'), + closeOnEscape: true, + modal: true, + closable: true, + data: this.currentUser()?.fullName, + }) + .onClose.pipe( + filter((value) => !!value), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((data: ContactDialogData) => this.sendEmailToUser(event.rowData, data)); + break; + } } - - this.router.navigate([], { - relativeTo: this.route, - queryParams, - queryParamsHandling: 'merge', - }); } - private parseQueryParams(params: Params): InstitutionProjectsQueryParamsModel { - const parsed = parseQueryFilterParams(params); - return { - ...parsed, - cursor: params['cursor'] || '', - }; + private sendEmailToUser(userRowData: TableCellData, emailData: ContactDialogData): void { + const userId = (userRowData['creator'] as TableCellLink).url.split('/').pop() || ''; + + if (emailData.selectedOption === ContactOption.SendMessage) { + this.actions + .sendUserMessage( + userId, + this.institutionId, + emailData.emailContent, + emailData.ccSender, + emailData.allowReplyToSender + ) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.toastService.showSuccess('adminInstitutions.institutionUsers.messageSent')); + } else { + const projectId = (userRowData['title'] as TableCellLink).url.split('/').pop() || ''; + + this.actions + .requestProjectAccess({ + userId, + projectId, + institutionId: this.institutionId, + permission: emailData.permission || '', + messageText: emailData.emailContent, + bccSender: emailData.ccSender, + replyTo: emailData.allowReplyToSender, + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.toastService.showSuccess('adminInstitutions.institutionUsers.requestSent')); + } } - private updateComponentState(params: InstitutionProjectsQueryParamsModel): void { - untracked(() => { - this.currentPageSize.set(params.size); - this.first.set((params.page - 1) * params.size); + private getProjects(): void { + const institutionId = this.route.parent?.snapshot.params['institution-id']; + if (!institutionId) return; - if (params.sortColumn) { - this.sortField.set(params.sortColumn); - const order = params.sortOrder === SortOrder.Desc ? -1 : 1; - this.sortOrder.set(order); - } - }); + this.institutionId = institutionId; + + const institution = this.institution() as Institution; + const institutionIris = institution.iris || []; + + this.actions.fetchProjects(this.institutionId, institutionIris, this.currentPageSize(), this.sortField(), ''); } private extractCursorFromUrl(url: string): string { diff --git a/src/app/features/admin-institutions/pages/institutions-registrations/institutions-registrations.component.html b/src/app/features/admin-institutions/pages/institutions-registrations/institutions-registrations.component.html index 586d48b27..a4c009522 100644 --- a/src/app/features/admin-institutions/pages/institutions-registrations/institutions-registrations.component.html +++ b/src/app/features/admin-institutions/pages/institutions-registrations/institutions-registrations.component.html @@ -2,16 +2,16 @@ -} @else if (tableData().length > 0) { +} @else { @@ -19,8 +19,4 @@ -} @else { - - {{ 'adminInstitutions.registrations.noData' | translate }} - } diff --git a/src/app/features/admin-institutions/pages/institutions-registrations/institutions-registrations.component.ts b/src/app/features/admin-institutions/pages/institutions-registrations/institutions-registrations.component.ts index c69e5b773..cfdcbbb3b 100644 --- a/src/app/features/admin-institutions/pages/institutions-registrations/institutions-registrations.component.ts +++ b/src/app/features/admin-institutions/pages/institutions-registrations/institutions-registrations.component.ts @@ -3,29 +3,22 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, computed, effect, inject, signal, untracked } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute, Params, Router } from '@angular/router'; - -import { AdminTableComponent } from '@osf/features/admin-institutions/components'; -import { registrationTableColumns } from '@osf/features/admin-institutions/constants'; -import { mapRegistrationToTableData } from '@osf/features/admin-institutions/mappers'; -import { - IndexSearchQueryParamsModel, - InstitutionProjectsQueryParamsModel, - TableCellData, -} from '@osf/features/admin-institutions/models'; -import { InstitutionsAdminSelectors } from '@osf/features/admin-institutions/store'; -import { LoadingSpinnerComponent } from '@osf/shared/components'; -import { TABLE_PARAMS } from '@shared/constants'; -import { SortOrder } from '@shared/enums'; -import { parseQueryFilterParams } from '@shared/helpers'; -import { Institution, QueryParams } from '@shared/models'; -import { InstitutionsSearchSelectors } from '@shared/stores'; - -import { FetchRegistrations } from '../../store/institutions-admin.actions'; +import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; -import { environment } from 'src/environments/environment'; +import { LoadingSpinnerComponent } from '@osf/shared/components'; +import { TABLE_PARAMS } from '@osf/shared/constants'; +import { SortOrder } from '@osf/shared/enums'; +import { Institution, QueryParams } from '@osf/shared/models'; +import { InstitutionsSearchSelectors } from '@osf/shared/stores'; + +import { AdminTableComponent } from '../../components'; +import { registrationTableColumns } from '../../constants'; +import { DownloadType } from '../../enums'; +import { downloadResults } from '../../helpers'; +import { mapRegistrationToTableData } from '../../mappers'; +import { TableCellData } from '../../models'; +import { FetchRegistrations, InstitutionsAdminSelectors } from '../../store'; @Component({ selector: 'osf-institutions-registrations', @@ -34,13 +27,11 @@ import { environment } from 'src/environments/environment'; styleUrl: './institutions-registrations.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class InstitutionsRegistrationsComponent { +export class InstitutionsRegistrationsComponent implements OnInit { private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); - private readonly actions = createDispatchMap({ - fetchRegistrations: FetchRegistrations, - }); + private readonly actions = createDispatchMap({ fetchRegistrations: FetchRegistrations }); private institutionId = ''; @@ -49,131 +40,62 @@ export class InstitutionsRegistrationsComponent { totalCount = select(InstitutionsAdminSelectors.getRegistrationsTotalCount); isLoading = select(InstitutionsAdminSelectors.getRegistrationsLoading); registrationsLinks = select(InstitutionsAdminSelectors.getRegistrationsLinks); + registrationsDownloadLink = select(InstitutionsAdminSelectors.getRegistrationsDownloadLink); tableColumns = signal(registrationTableColumns); - reportsLink = 'https://drive.google.com/drive/folders/1_aFmeJwLp5xBS3-8clZ4xA9L3UFxdzDd'; - queryParams = toSignal(this.route.queryParams); currentPageSize = signal(TABLE_PARAMS.rows); currentSort = signal('-dateModified'); sortField = signal('-dateModified'); sortOrder = signal(1); - tableData = computed(() => { - const registrationsData = this.registrations(); - return registrationsData.map(mapRegistrationToTableData) as TableCellData[]; - }); - - downloadLink = computed(() => { - const institution = this.institution(); - const queryParams = this.queryParams(); - - if (!institution?.iris?.length) { - return ''; - } - - const institutionIris = institution.iris.join(','); - const baseUrl = `${environment.shareDomainUrl}/index-card-search`; - let params = new URLSearchParams(); - if (queryParams) { - params = new URLSearchParams({ - 'cardSearchFilter[affiliation][]': institutionIris, - 'cardSearchFilter[resourceType]': 'Registration', - 'cardSearchFilter[accessService]': environment.webUrl, - 'page[size]': String(queryParams['size'] || this.currentPageSize()), - sort: queryParams['sort'] || this.currentSort(), - }); - } - - if (queryParams && queryParams['cursor']) { - params.append('page[cursor]', queryParams['cursor']); - } - - return `${baseUrl}?${params.toString()}`; - }); - - constructor() { - this.setupQueryParamsEffect(); + tableData = computed(() => this.registrations().map(mapRegistrationToTableData) as TableCellData[]); + + ngOnInit(): void { + this.getRegistrations(); } onSortChange(params: QueryParams): void { - this.updateQueryParams({ - sort: - params.sortColumn && params.sortOrder - ? params.sortOrder === SortOrder.Desc - ? `-${params.sortColumn}` - : params.sortColumn - : undefined, - }); + this.sortField.set(params.sortColumn || '-dateModified'); + this.sortOrder.set(params.sortOrder || 1); + + const sortField = params.sortColumn || '-dateModified'; + const sortOrder = params.sortOrder || 1; + const sortParam = sortOrder === SortOrder.Desc ? `-${sortField}` : sortField; + + const institution = this.institution() as Institution; + const institutionIris = institution.iris || []; + + this.actions.fetchRegistrations(this.institutionId, institutionIris, this.currentPageSize(), sortParam, ''); } onLinkPageChange(link: string): void { const url = new URL(link); const cursor = url.searchParams.get('page[cursor]') || ''; - this.updateQueryParams({ cursor }); - } - - private setupQueryParamsEffect(): void { - effect(() => { - const institutionId = this.route.parent?.snapshot.params['institution-id']; - const rawQueryParams = this.queryParams(); - if (!rawQueryParams && !institutionId) return; - - this.institutionId = institutionId; - const parsedQueryParams = this.parseQueryParams(rawQueryParams as Params); - this.updateComponentState(parsedQueryParams); + const sortField = this.sortField(); + const sortOrder = this.sortOrder(); + const sortParam = sortOrder === -1 ? `-${sortField}` : sortField; - const sortField = parsedQueryParams.sortColumn; - const sortOrder = parsedQueryParams.sortOrder; - const sortParam = sortOrder === SortOrder.Desc ? `-${sortField}` : sortField; - const cursor = parsedQueryParams.cursor; - const size = parsedQueryParams.size; + const institution = this.institution() as Institution; + const institutionIris = institution.iris || []; - const institution = this.institution() as Institution; - const institutionIris = institution.iris || []; - - this.actions.fetchRegistrations(this.institutionId, institutionIris, size, sortParam, cursor); - }); + this.actions.fetchRegistrations(this.institutionId, institutionIris, this.currentPageSize(), sortParam, cursor); } - private parseQueryParams(params: Params): InstitutionProjectsQueryParamsModel { - const parsed = parseQueryFilterParams(params); - return { - ...parsed, - cursor: params['cursor'] || '', - }; + download(type: DownloadType) { + downloadResults(this.registrationsDownloadLink(), type); } - private updateComponentState(params: InstitutionProjectsQueryParamsModel): void { - untracked(() => { - this.currentPageSize.set(params.size); + private getRegistrations(): void { + const institutionId = this.route.parent?.snapshot.params['institution-id']; + if (!institutionId) return; - if (params.sortColumn) { - this.sortField.set(params.sortColumn); - const order = params.sortOrder === SortOrder.Desc ? -1 : 1; - this.sortOrder.set(order); - } - }); - } + this.institutionId = institutionId; + + const institution = this.institution() as Institution; + const institutionIris = institution.iris || []; - private updateQueryParams(params: IndexSearchQueryParamsModel): void { - const queryParams: Record = {}; - - if (params.sort) { - queryParams['sort'] = params.sort; - } - if (params.cursor) { - queryParams['cursor'] = params.cursor; - } - if (params.size) { - queryParams['size'] = params.size.toString(); - } - - this.router.navigate([], { - relativeTo: this.route, - queryParams, - queryParamsHandling: 'merge', - }); + this.actions.fetchRegistrations(this.institutionId, institutionIris, this.currentPageSize(), this.sortField(), ''); } } diff --git a/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.html b/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.html index cd2a4694b..b38e96b27 100644 --- a/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.html +++ b/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.html @@ -2,7 +2,7 @@ -} @else if (tableData().length > 0) { +} @else { {{ amountText() }} @@ -32,9 +32,9 @@ {{ amountText() }} inputId="orcid-filter" > - {{ - 'adminInstitutions.institutionUsers.hasOrcid' | translate - }} + + {{ 'adminInstitutions.institutionUsers.hasOrcid' | translate }} + @@ -48,8 +48,4 @@ {{ amountText() }} -} @else { - - {{ 'adminInstitutions.institutionUsers.noData' | translate }} - } diff --git a/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.ts b/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.ts index b6159107a..b0f74e19f 100644 --- a/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.ts +++ b/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.ts @@ -17,37 +17,28 @@ import { inject, OnInit, signal, - untracked, } from '@angular/core'; -import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; -import { ActivatedRoute, Params, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; -import { UserSelectors } from '@core/store/user'; -import { AdminTableComponent } from '@osf/features/admin-institutions/components'; -import { departmentOptions, userTableColumns } from '@osf/features/admin-institutions/constants'; -import { SendEmailDialogComponent } from '@osf/features/admin-institutions/dialogs'; -import { mapUserToTableCellData } from '@osf/features/admin-institutions/mappers'; -import { - FetchInstitutionUsers, - SendUserMessage, -} from '@osf/features/admin-institutions/store/institutions-admin.actions'; +import { UserSelectors } from '@osf/core/store/user'; import { LoadingSpinnerComponent, SelectComponent } from '@osf/shared/components'; import { TABLE_PARAMS } from '@osf/shared/constants'; import { SortOrder } from '@osf/shared/enums'; -import { parseQueryFilterParams, Primitive } from '@osf/shared/helpers'; +import { Primitive } from '@osf/shared/helpers'; import { QueryParams } from '@osf/shared/models'; import { ToastService } from '@osf/shared/services'; +import { InstitutionsSearchSelectors } from '@osf/shared/stores'; -import { - InstitutionsUsersQueryParamsModel, - InstitutionUser, - SendEmailDialogData, - TableCellData, - TableCellLink, - TableIconClickEvent, -} from '../../models'; -import { InstitutionsAdminSelectors } from '../../store'; +import { AdminTableComponent } from '../../components'; +import { departmentOptions, userTableColumns } from '../../constants'; +import { SendEmailDialogComponent } from '../../dialogs'; +import { DownloadType } from '../../enums'; +import { camelToSnakeCase } from '../../helpers/camel-to-snake.helper'; +import { mapUserToTableCellData } from '../../mappers'; +import { InstitutionUser, SendEmailDialogData, TableCellData, TableCellLink, TableIconClickEvent } from '../../models'; +import { FetchInstitutionUsers, InstitutionsAdminSelectors, SendUserMessage } from '../../store'; @Component({ selector: 'osf-institutions-users', @@ -59,7 +50,6 @@ import { InstitutionsAdminSelectors } from '../../store'; }) export class InstitutionsUsersComponent implements OnInit { private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); private readonly translate = inject(TranslateService); private readonly dialogService = inject(DialogService); private readonly destroyRef = inject(DestroyRef); @@ -71,9 +61,7 @@ export class InstitutionsUsersComponent implements OnInit { }); institutionId = ''; - reportsLink = 'https://drive.google.com/drive/folders/1_aFmeJwLp5xBS3-8clZ4xA9L3UFxdzDd'; - queryParams = toSignal(this.route.queryParams); currentPage = signal(1); currentPageSize = signal(TABLE_PARAMS.rows); first = signal(0); @@ -82,12 +70,13 @@ export class InstitutionsUsersComponent implements OnInit { hasOrcidFilter = signal(false); sortField = signal('user_name'); - sortOrder = signal(1); + sortOrder = signal(SortOrder.Desc); departmentOptions = departmentOptions; tableColumns = userTableColumns; users = select(InstitutionsAdminSelectors.getUsers); + institution = select(InstitutionsSearchSelectors.getInstitution); totalCount = select(InstitutionsAdminSelectors.getUsersTotalCount); isLoading = select(InstitutionsAdminSelectors.getUsersLoading); @@ -103,7 +92,7 @@ export class InstitutionsUsersComponent implements OnInit { }); constructor() { - this.setupQueryParamsEffect(); + this.setupDataFetchingEffect(); } ngOnInit(): void { @@ -117,35 +106,24 @@ export class InstitutionsUsersComponent implements OnInit { onPageChange(event: PaginatorState): void { this.currentPage.set(event.page ? event.page + 1 : 1); this.first.set(event.first ?? 0); - this.updateQueryParams({ - page: this.currentPage(), - size: event.rows || this.currentPageSize(), - }); + this.currentPageSize.set(event.rows || this.currentPageSize()); } onDepartmentChange(department: Primitive): void { const departmentValue = department === null || department === undefined ? null : String(department); this.selectedDepartment.set(departmentValue); - this.updateQueryParams({ - department: departmentValue, - page: 1, - }); + this.currentPage.set(1); } onOrcidFilterChange(hasOrcid: boolean): void { this.hasOrcidFilter.set(hasOrcid); - this.updateQueryParams({ - hasOrcid: hasOrcid, - page: 1, - }); + this.currentPage.set(1); } onSortChange(sortEvent: QueryParams): void { - this.updateQueryParams({ - sortColumn: sortEvent.sortColumn, - sortOrder: sortEvent.sortOrder, - page: 1, - }); + this.currentPage.set(1); + this.sortField.set(camelToSnakeCase(sortEvent.sortColumn) || 'user_name'); + this.sortOrder.set(sortEvent.sortOrder); } onIconClick(event: TableIconClickEvent): void { @@ -171,83 +149,56 @@ export class InstitutionsUsersComponent implements OnInit { } } - private setupQueryParamsEffect(): void { - effect(() => { - const rawQueryParams = this.queryParams(); - if (!rawQueryParams) return; - - const parsedQueryParams = this.parseQueryParams(rawQueryParams); - this.updateComponentState(parsedQueryParams); - - if (this.institutionId) { - const filters = untracked(() => this.buildFilters()); - - const sortField = untracked(() => this.sortField()); - const sortOrder = untracked(() => this.sortOrder()); - const sortParam = sortOrder === -1 ? `-${sortField}` : sortField; - - this.actions.fetchInstitutionUsers( - this.institutionId, - parsedQueryParams.page, - parsedQueryParams.size, - sortParam, - filters - ); - } - }); - } - - private updateQueryParams(updates: Partial): void { - const queryParams: Record = {}; + download(type: DownloadType) { + const baseUrl = this.institution().userMetricsUrl; - if ('page' in updates) { - queryParams['page'] = updates.page!.toString(); - } - if ('size' in updates) { - queryParams['size'] = updates.size!.toString(); - } - if ('department' in updates) { - queryParams['department'] = updates.department || undefined; - } - if ('hasOrcid' in updates) { - queryParams['hasOrcid'] = updates.hasOrcid ? 'true' : undefined; + if (!baseUrl) { + return; } - if ('sortColumn' in updates) { - queryParams['sortColumn'] = updates.sortColumn || undefined; + + const url = this.createUrl(baseUrl, type); + + window.open(url, '_blank'); + } + + private createUrl(baseUrl: string, mediaType: string): string { + const query = {} as Record; + if (this.selectedDepartment()) { + query['filter[department]'] = this.selectedDepartment() || ''; } - if ('sortOrder' in updates) { - queryParams['sortOrder'] = updates.sortOrder?.toString() || undefined; + + if (this.hasOrcidFilter()) { + query['filter[orcid_id][ne]'] = ''; } - this.router.navigate([], { - relativeTo: this.route, - queryParams, - queryParamsHandling: 'merge', + const userURL = new URL(baseUrl); + userURL.searchParams.set('format', mediaType); + userURL.searchParams.set('page[size]', '10000'); + + Object.entries(query).forEach(([key, value]) => { + userURL.searchParams.set(key, value); }); - } - private parseQueryParams(params: Params): InstitutionsUsersQueryParamsModel { - const parsed = parseQueryFilterParams(params); - return { - ...parsed, - department: params['department'] || null, - hasOrcid: params['hasOrcid'] === 'true', - }; + return userURL.toString(); } - private updateComponentState(params: InstitutionsUsersQueryParamsModel): void { - untracked(() => { - this.currentPage.set(params.page); - this.currentPageSize.set(params.size); - this.first.set((params.page - 1) * params.size); - this.selectedDepartment.set(params.department || null); - this.hasOrcidFilter.set(params.hasOrcid || false); - - if (params.sortColumn) { - this.sortField.set(params.sortColumn); - const order = params.sortOrder === SortOrder.Desc ? -1 : 1; - this.sortOrder.set(order); - } + private setupDataFetchingEffect(): void { + effect(() => { + if (!this.institutionId) return; + + const filters = this.buildFilters(); + const sortField = this.sortField(); + const sortOrder = this.sortOrder(); + console.log(sortOrder); + const sortParam = sortOrder === 0 ? `-${sortField}` : sortField; + + this.actions.fetchInstitutionUsers( + this.institutionId, + this.currentPage(), + this.currentPageSize(), + sortParam, + filters + ); }); } diff --git a/src/app/features/admin-institutions/services/institutions-admin.service.ts b/src/app/features/admin-institutions/services/institutions-admin.service.ts index d2f504aaa..b2b7a466f 100644 --- a/src/app/features/admin-institutions/services/institutions-admin.service.ts +++ b/src/app/features/admin-institutions/services/institutions-admin.service.ts @@ -3,20 +3,22 @@ import { map } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; -import { mapInstitutionPreprints } from '@osf/features/admin-institutions/mappers/institution-preprints.mapper'; -import { PaginationLinksModel } from '@shared/models'; import { JsonApiService } from '@shared/services'; +import { SearchResourceType } from '../enums'; import { mapIndexCardResults, mapInstitutionDepartments, + mapInstitutionPreprints, mapInstitutionProjects, mapInstitutionRegistrations, mapInstitutionSummaryMetrics, mapInstitutionUsers, sendMessageRequestMapper, } from '../mappers'; +import { requestProjectAccessMapper } from '../mappers/request-access.mapper'; import { + AdminInstitutionSearchResult, InstitutionDepartment, InstitutionDepartmentsJsonApi, InstitutionIndexValueSearchJsonApi, @@ -29,6 +31,7 @@ import { InstitutionSummaryMetricsJsonApi, InstitutionUser, InstitutionUsersJsonApi, + RequestProjectAccessData, SendMessageRequest, SendMessageResponseJsonApi, } from '../models'; @@ -77,16 +80,16 @@ export class InstitutionsAdminService { ); } - fetchProjects(institutionId: string, iris: string[], pageSize = 10, sort = '-dateModified', cursor = '') { - return this.fetchIndexCards('Project', iris, pageSize, sort, cursor); + fetchProjects(iris: string[], pageSize = 10, sort = '-dateModified', cursor = '') { + return this.fetchIndexCards(SearchResourceType.Project, iris, pageSize, sort, cursor); } - fetchRegistrations(institutionId: string, iris: string[], pageSize = 10, sort = '-dateModified', cursor = '') { - return this.fetchIndexCards('Registration', iris, pageSize, sort, cursor); + fetchRegistrations(iris: string[], pageSize = 10, sort = '-dateModified', cursor = '') { + return this.fetchIndexCards(SearchResourceType.Registration, iris, pageSize, sort, cursor); } - fetchPreprints(institutionId: string, iris: string[], pageSize = 10, sort = '-dateModified', cursor = '') { - return this.fetchIndexCards('Preprint', iris, pageSize, sort, cursor); + fetchPreprints(iris: string[], pageSize = 10, sort = '-dateModified', cursor = '') { + return this.fetchIndexCards(SearchResourceType.Preprint, iris, pageSize, sort, cursor); } fetchIndexValueSearch( @@ -115,17 +118,19 @@ export class InstitutionsAdminService { ); } + requestProjectAccess(request: RequestProjectAccessData): Observable { + const payload = requestProjectAccessMapper(request); + + return this.jsonApiService.post(`${environment.apiUrl}/nodes/${request.projectId}/requests/`, payload); + } + private fetchIndexCards( - resourceType: 'Project' | 'Registration' | 'Preprint', + resourceType: SearchResourceType, institutionIris: string[], pageSize = 10, sort = '-dateModified', cursor = '' - ): Observable<{ - items: InstitutionProject[] | InstitutionRegistration[] | InstitutionPreprint[]; - totalCount: number; - links?: PaginationLinksModel; - }> { + ): Observable { const url = `${environment.shareDomainUrl}/index-card-search`; const affiliationParam = institutionIris.join(','); @@ -144,10 +149,10 @@ export class InstitutionsAdminService { response: InstitutionRegistrationsJsonApi ) => InstitutionProject[] | InstitutionRegistration[] | InstitutionPreprint[]; switch (resourceType) { - case 'Registration': + case SearchResourceType.Registration: mapper = mapInstitutionRegistrations; break; - case 'Project': + case SearchResourceType.Project: mapper = mapInstitutionProjects; break; default: @@ -159,6 +164,7 @@ export class InstitutionsAdminService { items: mapper(res), totalCount: res.data.attributes.totalResultCount, links: res.data.relationships.searchResultPage.links, + downloadLink: res.data.links.self || null, }; }) ); diff --git a/src/app/features/admin-institutions/store/institutions-admin.actions.ts b/src/app/features/admin-institutions/store/institutions-admin.actions.ts index 85bb41615..db8610293 100644 --- a/src/app/features/admin-institutions/store/institutions-admin.actions.ts +++ b/src/app/features/admin-institutions/store/institutions-admin.actions.ts @@ -1,20 +1,26 @@ +import { RequestProjectAccessData } from '../models'; + export class FetchInstitutionById { static readonly type = '[InstitutionsAdmin] Fetch Institution By Id'; + constructor(public institutionId: string) {} } export class FetchInstitutionDepartments { static readonly type = '[InstitutionsAdmin] Fetch Institution Departments'; + constructor(public institutionId: string) {} } export class FetchInstitutionSummaryMetrics { static readonly type = '[InstitutionsAdmin] Fetch Institution Summary Metrics'; + constructor(public institutionId: string) {} } export class FetchInstitutionSearchResults { static readonly type = '[InstitutionsAdmin] Fetch Institution Search Results'; + constructor( public institutionId: string, public valueSearchPropertyPath: string, @@ -24,16 +30,19 @@ export class FetchInstitutionSearchResults { export class FetchHasOsfAddonSearch { static readonly type = '[InstitutionsAdmin] Fetch Has OSF Addon Search'; + constructor(public institutionId: string) {} } export class FetchStorageRegionSearch { static readonly type = '[InstitutionsAdmin] Fetch Storage Region Search'; + constructor(public institutionId: string) {} } export class FetchInstitutionUsers { static readonly type = '[InstitutionsAdmin] Fetch Institution Users'; + constructor( public institutionId: string, public page = 1, @@ -45,6 +54,7 @@ export class FetchInstitutionUsers { export class FetchProjects { static readonly type = '[InstitutionsAdmin] Fetch Projects'; + constructor( public institutionId: string, public institutionIris: string[], @@ -56,6 +66,7 @@ export class FetchProjects { export class FetchRegistrations { static readonly type = '[InstitutionsAdmin] Fetch Registrations'; + constructor( public institutionId: string, public institutionIris: string[], @@ -67,6 +78,7 @@ export class FetchRegistrations { export class FetchPreprints { static readonly type = '[InstitutionsAdmin] Fetch Preprints'; + constructor( public institutionId: string, public institutionIris: string[], @@ -78,6 +90,7 @@ export class FetchPreprints { export class SendUserMessage { static readonly type = '[InstitutionsAdmin] Send User Message'; + constructor( public userId: string, public institutionId: string, @@ -86,3 +99,9 @@ export class SendUserMessage { public replyTo: boolean ) {} } + +export class RequestProjectAccess { + static readonly type = '[InstitutionsAdmin] Request Project Access'; + + constructor(public payload: RequestProjectAccessData) {} +} diff --git a/src/app/features/admin-institutions/store/institutions-admin.model.ts b/src/app/features/admin-institutions/store/institutions-admin.model.ts index ed0bc1f3f..e9d16898c 100644 --- a/src/app/features/admin-institutions/store/institutions-admin.model.ts +++ b/src/app/features/admin-institutions/store/institutions-admin.model.ts @@ -1,4 +1,4 @@ -import { AsyncStateModel, AsyncStateWithLinksModel, AsyncStateWithTotalCount, Institution } from '@shared/models'; +import { AsyncStateModel, AsyncStateWithTotalCount, Institution, PaginationLinksModel } from '@shared/models'; import { InstitutionDepartment, @@ -8,7 +8,6 @@ import { InstitutionSearchFilter, InstitutionSummaryMetrics, InstitutionUser, - SendMessageResponseJsonApi, } from '../models'; export interface InstitutionsAdminModel { @@ -18,15 +17,17 @@ export interface InstitutionsAdminModel { storageRegionSearch: AsyncStateModel; searchResults: AsyncStateModel; users: AsyncStateWithTotalCount; - projects: AsyncStateWithLinksModel; - registrations: AsyncStateWithLinksModel; - preprints: AsyncStateWithLinksModel; - sendMessage: AsyncStateModel; - selectedInstitutionId: string | null; - currentSearchPropertyPath: string | null; + projects: ResultStateModel; + registrations: ResultStateModel; + preprints: ResultStateModel; institution: AsyncStateModel; } +interface ResultStateModel extends AsyncStateWithTotalCount { + links?: PaginationLinksModel; + downloadLink: string | null; +} + export const INSTITUTIONS_ADMIN_STATE_DEFAULTS: InstitutionsAdminModel = { departments: { data: [], isLoading: false, error: null }, summaryMetrics: { data: {} as InstitutionSummaryMetrics, isLoading: false, error: null }, @@ -34,11 +35,8 @@ export const INSTITUTIONS_ADMIN_STATE_DEFAULTS: InstitutionsAdminModel = { storageRegionSearch: { data: [], isLoading: false, error: null }, searchResults: { data: [], isLoading: false, error: null }, users: { data: [], totalCount: 0, isLoading: false, error: null }, - projects: { data: [], totalCount: 0, isLoading: false, error: null, links: undefined }, - registrations: { data: [], totalCount: 0, isLoading: false, error: null, links: undefined }, - preprints: { data: [], totalCount: 0, isLoading: false, error: null, links: undefined }, - sendMessage: { data: null, isLoading: false, error: null }, - selectedInstitutionId: null, - currentSearchPropertyPath: null, + projects: { data: [], totalCount: 0, isLoading: false, error: null, links: undefined, downloadLink: null }, + registrations: { data: [], totalCount: 0, isLoading: false, error: null, links: undefined, downloadLink: null }, + preprints: { data: [], totalCount: 0, isLoading: false, error: null, links: undefined, downloadLink: null }, institution: { data: {} as Institution, isLoading: false, error: null }, }; diff --git a/src/app/features/admin-institutions/store/institutions-admin.selectors.ts b/src/app/features/admin-institutions/store/institutions-admin.selectors.ts index 6ab035c26..89352bd50 100644 --- a/src/app/features/admin-institutions/store/institutions-admin.selectors.ts +++ b/src/app/features/admin-institutions/store/institutions-admin.selectors.ts @@ -1,6 +1,6 @@ import { Selector } from '@ngxs/store'; -import { PaginationLinksModel } from '@shared/models'; +import { Institution, PaginationLinksModel } from '@shared/models'; import { InstitutionDepartment, @@ -10,7 +10,6 @@ import { InstitutionSearchFilter, InstitutionSummaryMetrics, InstitutionUser, - SendMessageResponseJsonApi, } from '../models'; import { InstitutionsAdminModel } from './institutions-admin.model'; @@ -27,11 +26,6 @@ export class InstitutionsAdminSelectors { return state.departments.isLoading; } - @Selector([InstitutionsAdminState]) - static getDepartmentsError(state: InstitutionsAdminModel): string | null { - return state.departments.error; - } - @Selector([InstitutionsAdminState]) static getSummaryMetrics(state: InstitutionsAdminModel): InstitutionSummaryMetrics { return state.summaryMetrics.data; @@ -42,11 +36,6 @@ export class InstitutionsAdminSelectors { return state.summaryMetrics.isLoading; } - @Selector([InstitutionsAdminState]) - static getSummaryMetricsError(state: InstitutionsAdminModel): string | null { - return state.summaryMetrics.error; - } - @Selector([InstitutionsAdminState]) static getHasOsfAddonSearch(state: InstitutionsAdminModel): InstitutionSearchFilter[] { return state.hasOsfAddonSearch.data; @@ -57,11 +46,6 @@ export class InstitutionsAdminSelectors { return state.hasOsfAddonSearch.isLoading; } - @Selector([InstitutionsAdminState]) - static getHasOsfAddonSearchError(state: InstitutionsAdminModel): string | null { - return state.hasOsfAddonSearch.error; - } - @Selector([InstitutionsAdminState]) static getStorageRegionSearch(state: InstitutionsAdminModel): InstitutionSearchFilter[] { return state.storageRegionSearch.data; @@ -72,11 +56,6 @@ export class InstitutionsAdminSelectors { return state.storageRegionSearch.isLoading; } - @Selector([InstitutionsAdminState]) - static getStorageRegionSearchError(state: InstitutionsAdminModel): string | null { - return state.storageRegionSearch.error; - } - @Selector([InstitutionsAdminState]) static getSearchResults(state: InstitutionsAdminModel): InstitutionSearchFilter[] { return state.searchResults.data; @@ -87,21 +66,6 @@ export class InstitutionsAdminSelectors { return state.searchResults.isLoading; } - @Selector([InstitutionsAdminState]) - static getSearchResultsError(state: InstitutionsAdminModel): string | null { - return state.searchResults.error; - } - - @Selector([InstitutionsAdminState]) - static getSelectedInstitutionId(state: InstitutionsAdminModel): string | null { - return state.selectedInstitutionId; - } - - @Selector([InstitutionsAdminState]) - static getCurrentSearchPropertyPath(state: InstitutionsAdminModel): string | null { - return state.currentSearchPropertyPath; - } - @Selector([InstitutionsAdminState]) static getUsers(state: InstitutionsAdminModel): InstitutionUser[] { return state.users.data; @@ -112,11 +76,6 @@ export class InstitutionsAdminSelectors { return state.users.isLoading; } - @Selector([InstitutionsAdminState]) - static getUsersError(state: InstitutionsAdminModel): string | null { - return state.users.error; - } - @Selector([InstitutionsAdminState]) static getUsersTotalCount(state: InstitutionsAdminModel): number { return state.users.totalCount; @@ -142,6 +101,11 @@ export class InstitutionsAdminSelectors { return state.projects.links; } + @Selector([InstitutionsAdminState]) + static getProjectsDownloadLink(state: InstitutionsAdminModel): string | null { + return state.projects.downloadLink; + } + @Selector([InstitutionsAdminState]) static getRegistrations(state: InstitutionsAdminModel): InstitutionRegistration[] { return state.registrations.data; @@ -162,6 +126,11 @@ export class InstitutionsAdminSelectors { return state.registrations.links; } + @Selector([InstitutionsAdminState]) + static getRegistrationsDownloadLink(state: InstitutionsAdminModel): string | null { + return state.registrations.downloadLink; + } + @Selector([InstitutionsAdminState]) static getPreprints(state: InstitutionsAdminModel): InstitutionPreprint[] { return state.preprints.data; @@ -183,17 +152,17 @@ export class InstitutionsAdminSelectors { } @Selector([InstitutionsAdminState]) - static getSendMessageResponse(state: InstitutionsAdminModel): SendMessageResponseJsonApi | null { - return state.sendMessage.data; + static getPreprintsDownloadLink(state: InstitutionsAdminModel): string | null { + return state.preprints.downloadLink; } @Selector([InstitutionsAdminState]) - static getSendMessageLoading(state: InstitutionsAdminModel): boolean { - return state.sendMessage.isLoading; + static getInstitution(state: InstitutionsAdminModel): Institution { + return state.institution.data; } @Selector([InstitutionsAdminState]) - static getSendMessageError(state: InstitutionsAdminModel): string | null { - return state.sendMessage.error; + static getInstitutionLoading(state: InstitutionsAdminModel): boolean { + return state.institution.isLoading; } } diff --git a/src/app/features/admin-institutions/store/institutions-admin.state.ts b/src/app/features/admin-institutions/store/institutions-admin.state.ts index ac235d780..f5f356f27 100644 --- a/src/app/features/admin-institutions/store/institutions-admin.state.ts +++ b/src/app/features/admin-institutions/store/institutions-admin.state.ts @@ -1,7 +1,7 @@ import { Action, State, StateContext } from '@ngxs/store'; import { patch } from '@ngxs/store/operators'; -import { catchError, tap } from 'rxjs'; +import { catchError, tap, throwError } from 'rxjs'; import { inject, Injectable } from '@angular/core'; @@ -23,6 +23,7 @@ import { FetchProjects, FetchRegistrations, FetchStorageRegionSearch, + RequestProjectAccess, SendUserMessage, } from './institutions-admin.actions'; import { INSTITUTIONS_ADMIN_STATE_DEFAULTS, InstitutionsAdminModel } from './institutions-admin.model'; @@ -92,7 +93,6 @@ export class InstitutionsAdminState { const state = ctx.getState(); ctx.patchState({ searchResults: { ...state.searchResults, isLoading: true, error: null }, - currentSearchPropertyPath: action.valueSearchPropertyPath, }); return this.institutionsAdminService @@ -168,7 +168,7 @@ export class InstitutionsAdminState { }); return this.institutionsAdminService - .fetchProjects(action.institutionId, action.institutionIris, action.pageSize, action.sort, action.cursor) + .fetchProjects(action.institutionIris, action.pageSize, action.sort, action.cursor) .pipe( tap((response) => { ctx.patchState({ @@ -178,6 +178,7 @@ export class InstitutionsAdminState { isLoading: false, error: null, links: response.links, + downloadLink: response.downloadLink, }, }); }), @@ -193,7 +194,7 @@ export class InstitutionsAdminState { }); return this.institutionsAdminService - .fetchRegistrations(action.institutionId, action.institutionIris, action.pageSize, action.sort, action.cursor) + .fetchRegistrations(action.institutionIris, action.pageSize, action.sort, action.cursor) .pipe( tap((response) => { ctx.patchState({ @@ -203,6 +204,7 @@ export class InstitutionsAdminState { isLoading: false, error: null, links: response.links, + downloadLink: response.downloadLink, }, }); }), @@ -218,7 +220,7 @@ export class InstitutionsAdminState { }); return this.institutionsAdminService - .fetchPreprints(action.institutionId, action.institutionIris, action.pageSize, action.sort, action.cursor) + .fetchPreprints(action.institutionIris, action.pageSize, action.sort, action.cursor) .pipe( tap((response) => { ctx.patchState({ @@ -228,6 +230,7 @@ export class InstitutionsAdminState { isLoading: false, error: null, links: response.links, + downloadLink: response.downloadLink, }, }); }), @@ -237,11 +240,6 @@ export class InstitutionsAdminState { @Action(SendUserMessage) sendUserMessage(ctx: StateContext, action: SendUserMessage) { - const state = ctx.getState(); - ctx.patchState({ - sendMessage: { ...state.sendMessage, isLoading: true, error: null }, - }); - return this.institutionsAdminService .sendMessage({ userId: action.userId, @@ -250,13 +248,13 @@ export class InstitutionsAdminState { bccSender: action.bccSender, replyTo: action.replyTo, }) - .pipe( - tap((response) => { - ctx.patchState({ - sendMessage: { data: response, isLoading: false, error: null }, - }); - }), - catchError((error) => handleSectionError(ctx, 'sendMessage', error)) - ); + .pipe(catchError((error) => throwError(() => error))); + } + + @Action(RequestProjectAccess) + requestProjectAccess(ctx: StateContext, action: RequestProjectAccess) { + return this.institutionsAdminService + .requestProjectAccess(action.payload) + .pipe(catchError((error) => throwError(() => error))); } } diff --git a/src/app/shared/mappers/institutions/general-institution.mapper.ts b/src/app/shared/mappers/institutions/general-institution.mapper.ts index 13dfbc583..065edc5b0 100644 --- a/src/app/shared/mappers/institutions/general-institution.mapper.ts +++ b/src/app/shared/mappers/institutions/general-institution.mapper.ts @@ -13,6 +13,8 @@ export class GeneralInstitutionMapper { assets: data.attributes.assets, institutionalRequestAccessEnabled: data.attributes.institutional_request_access_enabled, logoPath: data.attributes.logo_path, + userMetricsUrl: data.relationships.user_metrics.links.related.href, + linkToExternalReportsArchive: data.attributes.link_to_external_reports_archive, }; } diff --git a/src/app/shared/models/institutions/institution-json-api.model.ts b/src/app/shared/models/institutions/institution-json-api.model.ts index e5f78877a..2b824d75e 100644 --- a/src/app/shared/models/institutions/institution-json-api.model.ts +++ b/src/app/shared/models/institutions/institution-json-api.model.ts @@ -1,10 +1,5 @@ -import { Institution, InstitutionLinks } from '@shared/models'; +import { InstitutionData } from '@shared/models'; export interface InstitutionJsonApiModel { - data: { - attributes: Institution; - id: string; - links: InstitutionLinks; - }; - meta: { version: string }; + data: InstitutionData; } diff --git a/src/app/shared/models/institutions/institutions-json-api.model.ts b/src/app/shared/models/institutions/institutions-json-api.model.ts index 3791d92d4..4d1c0d9bd 100644 --- a/src/app/shared/models/institutions/institutions-json-api.model.ts +++ b/src/app/shared/models/institutions/institutions-json-api.model.ts @@ -26,6 +26,14 @@ export interface InstitutionRelationships { }; }; }; + user_metrics: { + links: { + related: { + href: string; + meta: Record; + }; + }; + }; } export interface InstitutionLinks { diff --git a/src/app/shared/models/institutions/institutions.models.ts b/src/app/shared/models/institutions/institutions.models.ts index 4f1f8b8a9..7c42fa874 100644 --- a/src/app/shared/models/institutions/institutions.models.ts +++ b/src/app/shared/models/institutions/institutions.models.ts @@ -13,6 +13,7 @@ export interface InstitutionAttributes { assets: InstitutionAssets; institutional_request_access_enabled: boolean; logo_path: string; + link_to_external_reports_archive: string; } export interface UserInstitutionGetResponse { @@ -32,4 +33,6 @@ export interface Institution { assets: InstitutionAssets; institutionalRequestAccessEnabled: boolean; logoPath: string; + userMetricsUrl?: string; + linkToExternalReportsArchive?: string; } diff --git a/src/app/shared/models/resource-card/resource.model.ts b/src/app/shared/models/resource-card/resource.model.ts index f3fd8cd8a..e1e2f6e89 100644 --- a/src/app/shared/models/resource-card/resource.model.ts +++ b/src/app/shared/models/resource-card/resource.model.ts @@ -1,5 +1,5 @@ -import { LinkItem } from '@osf/features/search/models/link-item.model'; -import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { LinkItem } from '@osf/features/search/models'; +import { ResourceType } from '@osf/shared/enums'; export interface Resource { id: string; diff --git a/src/app/shared/models/store/async-state-with-links.model.ts b/src/app/shared/models/store/async-state-with-links.model.ts deleted file mode 100644 index a741fa71e..000000000 --- a/src/app/shared/models/store/async-state-with-links.model.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { AsyncStateWithTotalCount, PaginationLinksModel } from '@shared/models'; - -export interface AsyncStateWithLinksModel extends AsyncStateWithTotalCount { - links?: PaginationLinksModel; -} diff --git a/src/app/shared/models/store/index.ts b/src/app/shared/models/store/index.ts index f129cbcc6..3b5469b1b 100644 --- a/src/app/shared/models/store/index.ts +++ b/src/app/shared/models/store/index.ts @@ -1,3 +1,2 @@ export type { AsyncStateModel } from './async-state.model'; -export type { AsyncStateWithLinksModel } from './async-state-with-links.model'; export type { AsyncStateWithTotalCount } from './async-state-with-total-count.model'; diff --git a/src/app/shared/services/institutions.service.ts b/src/app/shared/services/institutions.service.ts index aba8209cf..4a8afd5b7 100644 --- a/src/app/shared/services/institutions.service.ts +++ b/src/app/shared/services/institutions.service.ts @@ -56,8 +56,8 @@ export class InstitutionsService { getInstitutionById(institutionId: string): Observable { return this.jsonApiService - .get(`${environment.apiUrl}/institutions/${institutionId}`) - .pipe(map((result) => result.data.attributes)); + .get(`${environment.apiUrl}/institutions/${institutionId}/`) + .pipe(map((result) => GeneralInstitutionMapper.adaptInstitution(result.data))); } deleteUserInstitution(id: string, userId: string): Observable { diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 5a1c35c56..c5b082c64 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -2440,6 +2440,13 @@ "publicProjects": "Public Projects", "publicRegistrations": "Public Registrations" }, + "contact": { + "requestAccess": "Request Access", + "sendMessage": "Send Message", + "message": "I am an admin associated with Center For Open Science and I would like to be added to your project as a non-bibliographic contributor in order to assist with metadata curation please add me with the following permissions.", + "administrative": "Administrative", + "readWrite": "Read/Write" + }, "institutionUsers": { "allDepartments": "All departments", "lastLogin": "Last Login", @@ -2460,7 +2467,8 @@ "osfLink": "OSF Link", "orcid": "ORCID", "noData": "No users found", - "messageSent": "Message has been sent." + "messageSent": "Message has been sent.", + "requestSent": "Request has been sent." }, "projects": { "title": "Title",
{{ 'adminInstitutions.contact.message' | translate }}
{{ 'adminInstitutions.preprints.noData' | translate }}
{{ 'adminInstitutions.projects.noData' | translate }}
{{ 'adminInstitutions.registrations.noData' | translate }}
{{ 'adminInstitutions.institutionUsers.noData' | translate }}