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 new file mode 100644 index 000000000..6b64dba3a --- /dev/null +++ b/src/app/features/admin-institutions/components/admin-table/admin-table.component.html @@ -0,0 +1,133 @@ +
+
+ +
+ + + + + + + {{ item.label }} + + + + +
+ + + + +
+ + {{ 'adminInstitutions.institutionUsers.customize' | translate }} +
+
+ + + {{ item.header | translate }} + +
+ + @if (downloadLink()) { + + } + + @if (reportsLink()) { + + + + } +
+
+ + + + + @for (col of columns; track col.field) { + +
+ {{ col.header | translate }} + @if (col.sortable) { + + } +
+ + } + +
+ + + + @for (col of columns; track col.field) { + +
+ @if (col.isLink && isLink(rowData[col.field])) { + + {{ getCellValueWithFormatting(rowData[col.field], col) }} + + } @else { + {{ getCellValueWithFormatting(rowData[col.field], col) }} + } + + @if (col.showIcon) { + + } +
+ + } + +
+
+ +@if (enablePagination() && totalCount() > pageSize()) { +
+ +
+} diff --git a/src/app/features/admin-institutions/components/admin-table/admin-table.component.scss b/src/app/features/admin-institutions/components/admin-table/admin-table.component.scss new file mode 100644 index 000000000..ba786a529 --- /dev/null +++ b/src/app/features/admin-institutions/components/admin-table/admin-table.component.scss @@ -0,0 +1,21 @@ +.p-06 { + padding: 0.6rem; +} + +.hover-group { + .icon-button { + opacity: 0; + transition: opacity; + } + + &:hover { + .icon-button { + opacity: 100; + } + } +} + +.child-button-0-padding { + --p-button-padding-y: 0; + --p-button-icon-only-width: max-content; +} diff --git a/src/app/features/admin-institutions/components/admin-table/admin-table.component.spec.ts b/src/app/features/admin-institutions/components/admin-table/admin-table.component.spec.ts new file mode 100644 index 000000000..9fb5d03fe --- /dev/null +++ b/src/app/features/admin-institutions/components/admin-table/admin-table.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminTableComponent } from './admin-table.component'; + +describe('AdminTableComponent', () => { + let component: AdminTableComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AdminTableComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(AdminTableComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); 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 new file mode 100644 index 000000000..e5a90116c --- /dev/null +++ b/src/app/features/admin-institutions/components/admin-table/admin-table.component.ts @@ -0,0 +1,194 @@ +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { SortEvent } from 'primeng/api'; +import { Button, ButtonDirective } from 'primeng/button'; +import { Menu } from 'primeng/menu'; +import { MultiSelect } from 'primeng/multiselect'; +import { PaginatorState } from 'primeng/paginator'; +import { TableModule } from 'primeng/table'; +import { Tooltip } from 'primeng/tooltip'; + +import { ChangeDetectionStrategy, Component, computed, effect, inject, input, output, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { + TableCellData, + TableCellLink, + TableColumn, + TableIconClickEvent, +} from '@osf/features/admin-institutions/models'; +import { CustomPaginatorComponent } from '@osf/shared/components'; +import { SortOrder } from '@shared/enums'; +import { QueryParams } from '@shared/models'; + +@Component({ + selector: 'osf-admin-table', + imports: [ + MultiSelect, + TableModule, + FormsModule, + ButtonDirective, + CustomPaginatorComponent, + Tooltip, + TranslatePipe, + Button, + Menu, + ], + templateUrl: './admin-table.component.html', + styleUrl: './admin-table.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AdminTableComponent { + private readonly translateService = inject(TranslateService); + + tableColumns = input.required(); + tableData = input.required(); + + enablePagination = input(false); + totalCount = input(0); + currentPage = input(1); + pageSize = input(10); + first = input(0); + + sortField = input(''); + sortOrder = input(1); + + pageChanged = output(); + sortChanged = output(); + iconClicked = output(); + + downloadLink = 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, 'csv'), + }, + { + label: 'TSV', + icon: 'fa fa-file-alt', + link: this.createUrl(baseUrl, 'tsv'), + }, + { + label: 'JSON', + icon: 'fa fa-file-code', + link: this.createUrl(baseUrl, 'json'), + }, + ]; + }); + + selectedColumnsComputed = computed(() => { + const selected = this.selectedColumns(); + const allColumns = this.tableColumns(); + + if (selected.length === 0) { + return allColumns; + } + + return selected; + }); + + sortColumn = computed(() => this.sortField()); + currentSortOrder = computed(() => this.sortOrder()); + + constructor() { + effect(() => { + const columns = this.tableColumns(); + if (columns.length > 0 && this.selectedColumns().length === 0) { + this.selectedColumns.set(columns); + } + }); + } + + onColumnSelectionChange(selectedCols: TableColumn[]): void { + this.selectedColumns.set(selectedCols); + } + + onPageChange(event: PaginatorState): void { + this.pageChanged.emit(event); + } + + onSort(event: SortEvent): void { + if (event.field) { + this.sortChanged.emit({ + sortColumn: event.field, + sortOrder: event.order === -1 ? SortOrder.Desc : SortOrder.Asc, + } as QueryParams); + } + } + + onIconClick(rowData: TableCellData, column: TableColumn): void { + if (column.iconAction) { + this.iconClicked.emit({ + rowData, + column, + action: column.iconAction, + }); + } + } + + isLink(value: string | number | TableCellLink | undefined): value is TableCellLink { + return value !== null && value !== undefined && typeof value === 'object' && 'text' in value && 'url' in value; + } + + getCellValue(value: string | number | TableCellLink | undefined): string { + if (this.isLink(value)) { + return this.translateService.instant(value.text); + } + return this.translateService.instant(String(value)) || ''; + } + + getCellValueWithFormatting(value: string | number | TableCellLink | undefined, column: TableColumn): string { + if (this.isLink(value)) { + return this.translateService.instant(value.text); + } + + const stringValue = String(value); + + if (column.dateFormat && stringValue) { + return this.formatDate(stringValue, column.dateFormat); + } + + return this.translateService.instant(stringValue) || ''; + } + + private formatDate(value: string, format: string): string { + if (format === 'yyyy-mm-to-mm/yyyy') { + const yearMonthRegex = /^(\d{4})-(\d{2})$/; + const match = value.match(yearMonthRegex); + + if (match) { + const [, year, month] = match; + return `${month}/${year}`; + } + } + + return value; + } + + private createUrl(baseUrl: string, format: string): string { + return `${baseUrl}?format=${format}`; + } + + getLinkUrl(value: string | number | TableCellLink | undefined): string { + if (this.isLink(value)) { + return value.url; + } + return ''; + } + + getLinkTarget(value: string | number | TableCellLink | undefined, column: TableColumn): string { + if (this.isLink(value)) { + return value.target || column.linkTarget || '_self'; + } + return column.linkTarget || '_self'; + } +} diff --git a/src/app/features/admin-institutions/components/index.ts b/src/app/features/admin-institutions/components/index.ts new file mode 100644 index 000000000..48b38339b --- /dev/null +++ b/src/app/features/admin-institutions/components/index.ts @@ -0,0 +1 @@ +export { AdminTableComponent } from './admin-table/admin-table.component'; diff --git a/src/app/features/admin-institutions/constants/admin-table-columns.constant.ts b/src/app/features/admin-institutions/constants/admin-table-columns.constant.ts new file mode 100644 index 000000000..9eea223d4 --- /dev/null +++ b/src/app/features/admin-institutions/constants/admin-table-columns.constant.ts @@ -0,0 +1,44 @@ +import { TableColumn } from '@osf/features/admin-institutions/models'; + +export const userTableColumns: TableColumn[] = [ + { + field: 'userName', + header: 'settings.profileSettings.tabs.name', + sortable: true, + isLink: false, + linkTarget: '_blank', + showIcon: true, + iconClass: 'fa-solid fa-comment text-primary', + iconTooltip: 'adminInstitutions.institutionUsers.sendMessage', + iconAction: 'sendMessage', + }, + { field: 'department', header: 'settings.profileSettings.education.department', sortable: true }, + { field: 'userLink', header: 'adminInstitutions.institutionUsers.osfLink', isLink: false, linkTarget: '_blank' }, + { field: 'orcidId', header: 'adminInstitutions.institutionUsers.orcid', isLink: true, linkTarget: '_blank' }, + { field: 'publicProjects', header: 'adminInstitutions.summary.publicProjects', sortable: true }, + { field: 'privateProjects', header: 'adminInstitutions.summary.privateProjects', sortable: true }, + { + field: 'monthLastLogin', + header: 'adminInstitutions.institutionUsers.lastLogin', + sortable: true, + dateFormat: 'yyyy-mm-to-mm/yyyy', + }, + { + field: 'monthLastActive', + header: 'adminInstitutions.institutionUsers.lastActive', + sortable: true, + dateFormat: 'yyyy-mm-to-mm/yyyy', + }, + { + field: 'accountCreationDate', + header: 'adminInstitutions.institutionUsers.accountCreated', + sortable: true, + dateFormat: 'yyyy-mm-to-mm/yyyy', + }, + { field: 'publicRegistrationCount', header: 'adminInstitutions.summary.publicRegistrations', sortable: true }, + { field: 'embargoedRegistrationCount', header: 'adminInstitutions.summary.embargoedRegistrations', sortable: true }, + { field: 'publishedPreprintCount', header: 'adminInstitutions.institutionUsers.publishedPreprints', sortable: true }, + { field: 'publicFileCount', header: 'adminInstitutions.institutionUsers.publicFiles', sortable: true }, + { field: 'storageByteCount', header: 'adminInstitutions.institutionUsers.storageBytes', sortable: true }, + { field: 'contactsCount', header: 'adminInstitutions.institutionUsers.contacts', sortable: true }, +]; diff --git a/src/app/features/admin-institutions/constants/department-options.constant.ts b/src/app/features/admin-institutions/constants/department-options.constant.ts new file mode 100644 index 000000000..9ae885c16 --- /dev/null +++ b/src/app/features/admin-institutions/constants/department-options.constant.ts @@ -0,0 +1,7 @@ +import { SelectOption } from '@shared/models'; + +export const departmentOptions: SelectOption[] = [ + { label: 'adminInstitutions.institutionUsers.allDepartments', value: null }, + { label: 'N/A', value: 'N/A' }, + { label: 'QA', value: 'QA' }, +]; diff --git a/src/app/features/admin-institutions/constants/index.ts b/src/app/features/admin-institutions/constants/index.ts index e69de29bb..2d8e951ea 100644 --- a/src/app/features/admin-institutions/constants/index.ts +++ b/src/app/features/admin-institutions/constants/index.ts @@ -0,0 +1,3 @@ +export * from './admin-table-columns.constant'; +export * from './department-options.constant'; +export * from './resource-tab-option.constant'; diff --git a/src/app/features/admin-institutions/dialogs/index.ts b/src/app/features/admin-institutions/dialogs/index.ts new file mode 100644 index 000000000..fbe1dc741 --- /dev/null +++ b/src/app/features/admin-institutions/dialogs/index.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 000000000..117cdf935 --- /dev/null +++ b/src/app/features/admin-institutions/dialogs/send-email-dialog/send-email-dialog.component.html @@ -0,0 +1,60 @@ +
+
+ +
+ +
+ {{ 'adminInstitutions.institutionUsers.sincerelyYours' | translate }}, + {{ config.data }} +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
diff --git a/src/app/features/admin-institutions/dialogs/send-email-dialog/send-email-dialog.component.scss b/src/app/features/admin-institutions/dialogs/send-email-dialog/send-email-dialog.component.scss new file mode 100644 index 000000000..b7494fc20 --- /dev/null +++ b/src/app/features/admin-institutions/dialogs/send-email-dialog/send-email-dialog.component.scss @@ -0,0 +1,4 @@ +:host { + display: block; + padding: 0; +} diff --git a/src/app/features/admin-institutions/dialogs/send-email-dialog/send-email-dialog.component.spec.ts b/src/app/features/admin-institutions/dialogs/send-email-dialog/send-email-dialog.component.spec.ts new file mode 100644 index 000000000..e1bd2965f --- /dev/null +++ b/src/app/features/admin-institutions/dialogs/send-email-dialog/send-email-dialog.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SendEmailDialogComponent } from './send-email-dialog.component'; + +describe('SendEmailDialogComponent', () => { + let component: SendEmailDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SendEmailDialogComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(SendEmailDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/features/admin-institutions/dialogs/send-email-dialog/send-email-dialog.component.ts b/src/app/features/admin-institutions/dialogs/send-email-dialog/send-email-dialog.component.ts new file mode 100644 index 000000000..7c2a6be40 --- /dev/null +++ b/src/app/features/admin-institutions/dialogs/send-email-dialog/send-email-dialog.component.ts @@ -0,0 +1,41 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Checkbox } from 'primeng/checkbox'; +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { Textarea } from 'primeng/textarea'; + +import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +@Component({ + selector: 'osf-send-email-dialog', + imports: [FormsModule, Button, Checkbox, TranslatePipe, Textarea], + templateUrl: './send-email-dialog.component.html', + styleUrl: './send-email-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SendEmailDialogComponent { + private readonly dialogRef = inject(DynamicDialogRef); + readonly config = inject(DynamicDialogConfig); + + emailContent = signal(''); + ccSender = signal(false); + allowReplyToSender = signal(false); + currentUserName = signal(''); + + onCancel(): void { + this.dialogRef.close(); + } + + onSend(): void { + if (this.emailContent().trim()) { + const data = { + emailContent: this.emailContent(), + ccSender: this.ccSender(), + allowReplyToSender: this.allowReplyToSender(), + }; + this.dialogRef.close(data); + } + } +} diff --git a/src/app/features/admin-institutions/mappers/index.ts b/src/app/features/admin-institutions/mappers/index.ts index 5c7fed63d..5b8786253 100644 --- a/src/app/features/admin-institutions/mappers/index.ts +++ b/src/app/features/admin-institutions/mappers/index.ts @@ -1,2 +1,4 @@ export { mapInstitutionDepartment, mapInstitutionDepartments } from './institution-departments.mapper'; export { mapInstitutionSummaryMetrics } from './institution-summary-metrics.mapper'; +export { mapUserToTableCellData } from './institution-user-to-table-data.mapper'; +export { mapInstitutionUsers } from './institution-users.mapper'; diff --git a/src/app/features/admin-institutions/mappers/institution-user-to-table-data.mapper.ts b/src/app/features/admin-institutions/mappers/institution-user-to-table-data.mapper.ts new file mode 100644 index 000000000..590b9c9e4 --- /dev/null +++ b/src/app/features/admin-institutions/mappers/institution-user-to-table-data.mapper.ts @@ -0,0 +1,40 @@ +import { InstitutionUser, TableCellData } from '@osf/features/admin-institutions/models'; + +export function mapUserToTableCellData(user: InstitutionUser): TableCellData { + return { + id: user.id, + userName: user.userName + ? { + text: user.userName, + url: user.userLink, + target: '_blank', + } + : '-', + department: user.department || '-', + userLink: user.userLink + ? { + text: user.userId, + url: user.userLink, + target: '_blank', + } + : '-', + orcidId: user.orcidId + ? { + text: user.orcidId, + url: `https://orcid.org/${user.orcidId}`, + target: '_blank', + } + : '-', + monthLastLogin: user.monthLastLogin, + monthLastActive: user.monthLastActive, + accountCreationDate: user.accountCreationDate, + publicProjects: user.publicProjects, + privateProjects: user.privateProjects, + publicRegistrationCount: user.publicRegistrationCount, + embargoedRegistrationCount: user.embargoedRegistrationCount, + publishedPreprintCount: user.publishedPreprintCount, + publicFileCount: user.publicFileCount, + storageByteCount: user.storageByteCount, + contactsCount: user.contactsCount, + }; +} diff --git a/src/app/features/admin-institutions/mappers/institution-users.mapper.ts b/src/app/features/admin-institutions/mappers/institution-users.mapper.ts new file mode 100644 index 000000000..6a406fc15 --- /dev/null +++ b/src/app/features/admin-institutions/mappers/institution-users.mapper.ts @@ -0,0 +1,27 @@ +import { + InstitutionUser, + InstitutionUserDataJsonApi, + InstitutionUsersJsonApi, +} from '@osf/features/admin-institutions/models'; + +export function mapInstitutionUsers(jsonApiData: InstitutionUsersJsonApi): InstitutionUser[] { + return jsonApiData.data.map((user: InstitutionUserDataJsonApi) => ({ + id: user.id, + userName: user.attributes.user_name, + department: user.attributes.department, + orcidId: user.attributes.orcid_id, + monthLastLogin: user.attributes.month_last_login, + monthLastActive: user.attributes.month_last_active, + accountCreationDate: user.attributes.account_creation_date, + publicProjects: user.attributes.public_projects, + privateProjects: user.attributes.private_projects, + publicRegistrationCount: user.attributes.public_registration_count, + embargoedRegistrationCount: user.attributes.embargoed_registration_count, + publishedPreprintCount: user.attributes.published_preprint_count, + publicFileCount: user.attributes.public_file_count, + storageByteCount: user.attributes.storage_byte_count, + contactsCount: user.attributes.contacts.length, + userId: user.relationships.user.data.id, + userLink: user.relationships.user.links.related.href, + })); +} diff --git a/src/app/features/admin-institutions/mappers/send-message-request.mapper.ts b/src/app/features/admin-institutions/mappers/send-message-request.mapper.ts new file mode 100644 index 000000000..6668bbb1e --- /dev/null +++ b/src/app/features/admin-institutions/mappers/send-message-request.mapper.ts @@ -0,0 +1,23 @@ +import { SendMessageRequest } from '@osf/features/admin-institutions/models'; + +export function sendMessageRequestMapper(request: SendMessageRequest) { + return { + data: { + attributes: { + message_text: request.messageText, + message_type: 'institutional_request', + bcc_sender: request.bccSender, + reply_to: request.replyTo, + }, + relationships: { + institution: { + data: { + type: 'institutions', + id: request.institutionId, + }, + }, + }, + type: 'user_messages', + }, + }; +} diff --git a/src/app/features/admin-institutions/models/index.ts b/src/app/features/admin-institutions/models/index.ts index 927d91012..fe82ed31a 100644 --- a/src/app/features/admin-institutions/models/index.ts +++ b/src/app/features/admin-institutions/models/index.ts @@ -4,3 +4,10 @@ export * from './institution-index-value-search-json-api.model'; export * from './institution-search-filter.model'; export * from './institution-summary-metric.model'; 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 './send-email-dialog-data.model'; +export * from './send-message-json-api.model'; +export * from './send-message-request.model'; +export * from './table.model'; diff --git a/src/app/features/admin-institutions/models/institution-user.model.ts b/src/app/features/admin-institutions/models/institution-user.model.ts new file mode 100644 index 000000000..85782687c --- /dev/null +++ b/src/app/features/admin-institutions/models/institution-user.model.ts @@ -0,0 +1,19 @@ +export interface InstitutionUser { + id: string; + userName: string; + department: string | null; + orcidId: string | null; + monthLastLogin: string; + monthLastActive: string; + accountCreationDate: string; + publicProjects: number; + privateProjects: number; + publicRegistrationCount: number; + embargoedRegistrationCount: number; + publishedPreprintCount: number; + publicFileCount: number; + storageByteCount: number; + contactsCount: number; + userId: string; + userLink: string; +} diff --git a/src/app/features/admin-institutions/models/institution-users-json-api.model.ts b/src/app/features/admin-institutions/models/institution-users-json-api.model.ts new file mode 100644 index 000000000..3fc14bafc --- /dev/null +++ b/src/app/features/admin-institutions/models/institution-users-json-api.model.ts @@ -0,0 +1,68 @@ +import { MetaJsonApi } from '@core/models'; + +export interface InstitutionUserContactJsonApi { + sender_name: string; + count: number; +} + +export interface InstitutionUserAttributesJsonApi { + report_yearmonth: string; + user_name: string; + department: string | null; + orcid_id: string | null; + month_last_login: string; + month_last_active: string; + account_creation_date: string; + public_projects: number; + private_projects: number; + public_registration_count: number; + embargoed_registration_count: number; + published_preprint_count: number; + public_file_count: number; + storage_byte_count: number; + contacts: InstitutionUserContactJsonApi[]; +} + +export interface InstitutionUserRelationshipDataJsonApi { + id: string; + type: string; +} + +export interface InstitutionUserRelationshipLinksJsonApi { + related: { + href: string; + meta: Record; + }; +} + +export interface InstitutionUserRelationshipJsonApi { + links: InstitutionUserRelationshipLinksJsonApi; + data: InstitutionUserRelationshipDataJsonApi; +} + +export interface InstitutionUserRelationshipsJsonApi { + user: InstitutionUserRelationshipJsonApi; + institution: InstitutionUserRelationshipJsonApi; +} + +export interface InstitutionUserDataJsonApi { + id: string; + type: 'institution-users'; + attributes: InstitutionUserAttributesJsonApi; + relationships: InstitutionUserRelationshipsJsonApi; + links: Record; +} + +export interface InstitutionUsersLinksJsonApi { + self: string; + first: string | null; + last: string | null; + prev: string | null; + next: string | null; +} + +export interface InstitutionUsersJsonApi { + data: InstitutionUserDataJsonApi[]; + meta: MetaJsonApi; + links: InstitutionUsersLinksJsonApi; +} diff --git a/src/app/features/admin-institutions/models/institution-users-query-params.model.ts b/src/app/features/admin-institutions/models/institution-users-query-params.model.ts new file mode 100644 index 000000000..dfc71813c --- /dev/null +++ b/src/app/features/admin-institutions/models/institution-users-query-params.model.ts @@ -0,0 +1,6 @@ +import { QueryParams } from '@shared/models'; + +export interface InstitutionsUsersQueryParamsModel extends QueryParams { + department?: string | null; + hasOrcid?: boolean; +} diff --git a/src/app/features/admin-institutions/models/send-email-dialog-data.model.ts b/src/app/features/admin-institutions/models/send-email-dialog-data.model.ts new file mode 100644 index 000000000..dd4caea4d --- /dev/null +++ b/src/app/features/admin-institutions/models/send-email-dialog-data.model.ts @@ -0,0 +1,5 @@ +export interface SendEmailDialogData { + emailContent: string; + ccSender: boolean; + allowReplyToSender: boolean; +} diff --git a/src/app/features/admin-institutions/models/send-message-json-api.model.ts b/src/app/features/admin-institutions/models/send-message-json-api.model.ts new file mode 100644 index 000000000..e561865da --- /dev/null +++ b/src/app/features/admin-institutions/models/send-message-json-api.model.ts @@ -0,0 +1,12 @@ +export interface SendMessageResponseJsonApi { + data: { + id: string; + type: string; + attributes: { + message_text: string; + message_type: string; + bcc_sender: boolean; + reply_to: boolean; + }; + }; +} diff --git a/src/app/features/admin-institutions/models/send-message-request.model.ts b/src/app/features/admin-institutions/models/send-message-request.model.ts new file mode 100644 index 000000000..312ab826d --- /dev/null +++ b/src/app/features/admin-institutions/models/send-message-request.model.ts @@ -0,0 +1,7 @@ +export interface SendMessageRequest { + userId: string; + institutionId: string; + messageText: string; + bccSender: boolean; + replyTo: boolean; +} diff --git a/src/app/features/admin-institutions/models/table.model.ts b/src/app/features/admin-institutions/models/table.model.ts new file mode 100644 index 000000000..542df0b1f --- /dev/null +++ b/src/app/features/admin-institutions/models/table.model.ts @@ -0,0 +1,26 @@ +export interface TableColumn { + field: string; + header: string; + sortable?: boolean; + isLink?: boolean; + linkTarget?: '_blank' | '_self'; + showIcon?: boolean; + iconClass?: string; + iconTooltip?: string; + iconAction?: string; + dateFormat?: 'yyyy-mm-to-mm/yyyy' | 'default'; +} + +export interface TableCellLink { + text: string; + url: string; + target?: '_blank' | '_self'; +} + +export type TableCellData = Record; + +export interface TableIconClickEvent { + rowData: TableCellData; + column: TableColumn; + action: string; +} 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 d6165891c..5943efff7 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 @@ -1,4 +1,46 @@ -
-

Institution Users

-

Users content will be implemented here...

+
+ +
+

{{ amountText() }}

+
+ +
+
+ + + +
+ +
+ +
+
+
diff --git a/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.scss b/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.scss index e69de29bb..eab134e2c 100644 --- a/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.scss +++ b/src/app/features/admin-institutions/pages/institutions-users/institutions-users.component.scss @@ -0,0 +1,3 @@ +.title { + color: var(--pr-blue-1); +} 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 ba4465712..ae7802897 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 @@ -1,10 +1,278 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { CheckboxModule } from 'primeng/checkbox'; +import { DialogService } from 'primeng/dynamicdialog'; +import { PaginatorState } from 'primeng/paginator'; + +import { filter } from 'rxjs'; + +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + effect, + inject, + OnInit, + signal, + untracked, +} from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; +import { ActivatedRoute, Params, Router } from '@angular/router'; + +import { parseQueryFilterParams } from '@core/helpers'; +import { UserSelectors } from '@core/store/user'; +import { Primitive } from '@osf/core/helpers'; +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 { InstitutionsAdminSelectors } from '@osf/features/admin-institutions/store/institutions-admin.selectors'; +import { SelectComponent } from '@osf/shared/components'; +import { TABLE_PARAMS } from '@shared/constants'; +import { SortOrder } from '@shared/enums'; +import { QueryParams } from '@shared/models'; + +import { + InstitutionsUsersQueryParamsModel, + InstitutionUser, + SendEmailDialogData, + TableCellData, + TableCellLink, + TableIconClickEvent, +} from '../../models'; @Component({ selector: 'osf-institutions-users', - imports: [], + imports: [AdminTableComponent, FormsModule, SelectComponent, CheckboxModule, TranslatePipe], templateUrl: './institutions-users.component.html', styleUrl: './institutions-users.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, + providers: [DialogService], }) -export class InstitutionsUsersComponent {} +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); + + private readonly actions = createDispatchMap({ + fetchInstitutionUsers: FetchInstitutionUsers, + sendUserMessage: SendUserMessage, + }); + + institutionId = ''; + + queryParams = toSignal(this.route.queryParams); + currentPage = signal(1); + currentPageSize = signal(TABLE_PARAMS.rows); + first = signal(0); + + selectedDepartment = signal(null); + hasOrcidFilter = signal(false); + + sortField = signal('user_name'); + sortOrder = signal(1); + + departmentOptions = departmentOptions; + tableColumns = userTableColumns; + + users = select(InstitutionsAdminSelectors.getUsers); + totalCount = select(InstitutionsAdminSelectors.getUsersTotalCount); + isLoading = select(InstitutionsAdminSelectors.getUsersLoading); + + currentUser = select(UserSelectors.getCurrentUser); + + tableData = computed(() => { + return this.users().map((user: InstitutionUser): TableCellData => mapUserToTableCellData(user)); + }); + + amountText = computed(() => { + const count = this.totalCount(); + return count + ' ' + this.translate.instant('adminInstitutions.summary.totalUsers').toLowerCase(); + }); + + constructor() { + this.setupQueryParamsEffect(); + } + + ngOnInit(): void { + const institutionId = this.route.parent?.snapshot.params['institution-id']; + + if (institutionId) { + this.institutionId = institutionId; + } + } + + 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(), + }); + } + + onDepartmentChange(department: Primitive): void { + const departmentValue = department === null || department === undefined ? null : String(department); + this.selectedDepartment.set(departmentValue); + this.updateQueryParams({ + department: departmentValue, + page: 1, + }); + } + + onOrcidFilterChange(hasOrcid: boolean): void { + this.hasOrcidFilter.set(hasOrcid); + this.updateQueryParams({ + hasOrcid: hasOrcid, + page: 1, + }); + } + + onSortChange(sortEvent: QueryParams): void { + this.updateQueryParams({ + sortColumn: sortEvent.sortColumn, + sortOrder: sortEvent.sortOrder, + page: 1, + }); + } + + onIconClick(event: TableIconClickEvent): void { + switch (event.action) { + case 'sendMessage': { + this.dialogService + .open(SendEmailDialogComponent, { + 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: SendEmailDialogData) => this.sendEmailToUser(event.rowData, data)); + break; + } + } + } + + 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 = {}; + + 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 ('sortColumn' in updates) { + queryParams['sortColumn'] = updates.sortColumn || undefined; + } + if ('sortOrder' in updates) { + queryParams['sortOrder'] = updates.sortOrder?.toString() || undefined; + } + + this.router.navigate([], { + relativeTo: this.route, + queryParams, + queryParamsHandling: 'merge', + }); + } + + private parseQueryParams(params: Params): InstitutionsUsersQueryParamsModel { + const parsed = parseQueryFilterParams(params); + return { + ...parsed, + department: params['department'] || null, + hasOrcid: params['hasOrcid'] === 'true', + }; + } + + 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 buildFilters(): Record { + const filters: Record = {}; + + const department = this.selectedDepartment(); + if (department !== null) { + filters['filter[department]'] = department; + } + + if (this.hasOrcidFilter()) { + filters['filter[orcid_id][ne]'] = ''; + } + + return filters; + } + + private sendEmailToUser(userRowData: TableCellData, emailData: SendEmailDialogData): void { + const userId = (userRowData['userLink'] as TableCellLink).text as string; + + this.actions.sendUserMessage( + userId, + this.institutionId, + emailData.emailContent, + emailData.ccSender, + emailData.allowReplyToSender + ); + } +} 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 deb219531..4310e1b2b 100644 --- a/src/app/features/admin-institutions/services/institutions-admin.service.ts +++ b/src/app/features/admin-institutions/services/institutions-admin.service.ts @@ -4,9 +4,9 @@ import { inject, Injectable } from '@angular/core'; import { JsonApiService } from '@core/services'; import { mapIndexCardResults } from '@osf/features/admin-institutions/mappers/institution-summary-index.mapper'; -import { departmens, summaryMetrics } from '@osf/features/admin-institutions/services/mock'; +import { sendMessageRequestMapper } from '@osf/features/admin-institutions/mappers/send-message-request.mapper'; +import { departmens, summaryMetrics, users } from '@osf/features/admin-institutions/services/mock'; -import { environment } from '../../../../environments/environment'; import { InstitutionDepartment, InstitutionDepartmentsJsonApi, @@ -14,9 +14,18 @@ import { InstitutionSearchFilter, InstitutionSummaryMetrics, InstitutionSummaryMetricsJsonApi, + InstitutionUser, + InstitutionUsersJsonApi, + SendMessageRequest, + SendMessageResponseJsonApi, } from '../models'; -import { mapInstitutionDepartments, mapInstitutionSummaryMetrics } from 'src/app/features/admin-institutions/mappers'; +import { + mapInstitutionDepartments, + mapInstitutionSummaryMetrics, + mapInstitutionUsers, +} from 'src/app/features/admin-institutions/mappers'; +import { environment } from 'src/environments/environment'; @Injectable({ providedIn: 'root', @@ -50,6 +59,34 @@ export class InstitutionsAdminService { ); } + fetchUsers( + institutionId: string, + page = 1, + pageSize = 10, + sort = 'user_name', + filters?: Record + ): Observable<{ users: InstitutionUser[]; totalCount: number }> { + const params: Record = { + page: page.toString(), + 'page[size]': pageSize.toString(), + sort, + ...filters, + }; + + return this.jsonApiService + .get(`${this.hardcodedUrl}/institutions/${institutionId}/metrics/users/`, params) + .pipe( + //TODO: remove mock data + catchError(() => { + return of(users); + }), + map((response) => ({ + users: mapInstitutionUsers(response as InstitutionUsersJsonApi), + totalCount: response.meta.total, + })) + ); + } + fetchIndexValueSearch( institutionId: string, valueSearchPropertyPath: string, @@ -67,4 +104,13 @@ export class InstitutionsAdminService { .get(`${environment.shareDomainUrl}/index-value-search`, params) .pipe(map((response) => mapIndexCardResults(response?.included))); } + + sendMessage(request: SendMessageRequest): Observable { + const payload = sendMessageRequestMapper(request); + + return this.jsonApiService.post( + `${this.hardcodedUrl}/users/${request.userId}/messages/`, + payload + ); + } } diff --git a/src/app/features/admin-institutions/services/mock.ts b/src/app/features/admin-institutions/services/mock.ts index 1538c7dc7..36742f972 100644 --- a/src/app/features/admin-institutions/services/mock.ts +++ b/src/app/features/admin-institutions/services/mock.ts @@ -88,3 +88,505 @@ export const summaryMetrics = { version: '2.20', }, }; + +export const users = { + data: [ + { + id: '38fa674d7bbb183efadf86070cdbe996c64ee2d25b4c74d9cd524a410b5f1f98', + type: 'institution-users', + attributes: { + report_yearmonth: '2025-06', + user_name: 'Doug Corell', + department: null, + orcid_id: '0000-0003-0945-8731', + month_last_login: '2023-01', + month_last_active: '2022-03', + account_creation_date: '2021-03', + public_projects: 0, + private_projects: 8, + public_registration_count: 3, + embargoed_registration_count: 0, + published_preprint_count: 1, + public_file_count: 4, + storage_byte_count: 4600004315, + contacts: [], + }, + relationships: { + user: { + links: { + related: { + href: 'https://api.test.osf.io/v2/users/f5vmj/', + meta: {}, + }, + }, + data: { + id: 'f5vmj', + type: 'users', + }, + }, + institution: { + links: { + related: { + href: 'https://api.test.osf.io/v2/institutions/cos/', + meta: {}, + }, + }, + data: { + id: 'cos', + type: 'institutions', + }, + }, + }, + links: {}, + }, + { + id: '34aad14d5eec09580661d9db38967467695f158063b24a6f21a1d533d6cf15c2', + type: 'institution-users', + attributes: { + report_yearmonth: '2025-06', + user_name: 'Blaine Butler', + department: null, + orcid_id: null, + month_last_login: '2025-06', + month_last_active: '2025-06', + account_creation_date: '2022-07', + public_projects: 27, + private_projects: 43, + public_registration_count: 65, + embargoed_registration_count: 4, + published_preprint_count: 76, + public_file_count: 1149, + storage_byte_count: 2519450324, + contacts: [ + { + sender_name: 'Blaine Butler', + count: 1, + }, + ], + }, + relationships: { + user: { + links: { + related: { + href: 'https://api.test.osf.io/v2/users/atryg/', + meta: {}, + }, + }, + data: { + id: 'atryg', + type: 'users', + }, + }, + institution: { + links: { + related: { + href: 'https://api.test.osf.io/v2/institutions/cos/', + meta: {}, + }, + }, + data: { + id: 'cos', + type: 'institutions', + }, + }, + }, + links: {}, + }, + { + id: '36fb6eac48f8d2329efebbea32fdeb2037781c2fdc7f810a192138619ec254fb', + type: 'institution-users', + attributes: { + report_yearmonth: '2025-06', + user_name: 'Ramyashri Virajamangala', + department: null, + orcid_id: '0009-0001-7982-6352', + month_last_login: '2025-07', + month_last_active: '2025-06', + account_creation_date: '2023-10', + public_projects: 11, + private_projects: 12, + public_registration_count: 11, + embargoed_registration_count: 0, + published_preprint_count: 25, + public_file_count: 137, + storage_byte_count: 253874264, + contacts: [], + }, + relationships: { + user: { + links: { + related: { + href: 'https://api.test.osf.io/v2/users/kzqhy/', + meta: {}, + }, + }, + data: { + id: 'kzqhy', + type: 'users', + }, + }, + institution: { + links: { + related: { + href: 'https://api.test.osf.io/v2/institutions/cos/', + meta: {}, + }, + }, + data: { + id: 'cos', + type: 'institutions', + }, + }, + }, + links: {}, + }, + { + id: '5cecc442620ea58a48408ef7b0e095f054bd98ff89cf64e26cbc3a2022843b96', + type: 'institution-users', + attributes: { + report_yearmonth: '2025-06', + user_name: 'Eric Olson', + department: null, + orcid_id: '0000-0002-5989-8244', + month_last_login: '2025-06', + month_last_active: '2025-06', + account_creation_date: '2020-02', + public_projects: 12, + private_projects: 20, + public_registration_count: 25, + embargoed_registration_count: 0, + published_preprint_count: 38, + public_file_count: 95, + storage_byte_count: 230015709, + contacts: [], + }, + relationships: { + user: { + links: { + related: { + href: 'https://api.test.osf.io/v2/users/cdr63/', + meta: {}, + }, + }, + data: { + id: 'cdr63', + type: 'users', + }, + }, + institution: { + links: { + related: { + href: 'https://api.test.osf.io/v2/institutions/cos/', + meta: {}, + }, + }, + data: { + id: 'cos', + type: 'institutions', + }, + }, + }, + links: {}, + }, + { + id: 'f81e4a6d479ea6c50b3b200be4de9f3f871ab99c5f1138714a29564372d72c72', + type: 'institution-users', + attributes: { + report_yearmonth: '2025-06', + user_name: 'Daniel Steger', + department: null, + orcid_id: null, + month_last_login: '2025-06', + month_last_active: '2025-04', + account_creation_date: '2021-09', + public_projects: 13, + private_projects: 17, + public_registration_count: 18, + embargoed_registration_count: 0, + published_preprint_count: 16, + public_file_count: 3562, + storage_byte_count: 150733824, + contacts: [], + }, + relationships: { + user: { + links: { + related: { + href: 'https://api.test.osf.io/v2/users/fs8ux/', + meta: {}, + }, + }, + data: { + id: 'fs8ux', + type: 'users', + }, + }, + institution: { + links: { + related: { + href: 'https://api.test.osf.io/v2/institutions/cos/', + meta: {}, + }, + }, + data: { + id: 'cos', + type: 'institutions', + }, + }, + }, + links: {}, + }, + { + id: '4144d8ae33cbdbd94f24f0e1534593c1217f356d5de1928a9dee3bee77c85921', + type: 'institution-users', + attributes: { + report_yearmonth: '2025-06', + user_name: 'Sara Bowman', + department: null, + orcid_id: null, + month_last_login: '2020-07', + month_last_active: '2022-10', + account_creation_date: '2017-12', + public_projects: 9, + private_projects: 4, + public_registration_count: 20, + embargoed_registration_count: 0, + published_preprint_count: 35, + public_file_count: 130, + storage_byte_count: 146351639, + contacts: [], + }, + relationships: { + user: { + links: { + related: { + href: 'https://api.test.osf.io/v2/users/3pwky/', + meta: {}, + }, + }, + data: { + id: '3pwky', + type: 'users', + }, + }, + institution: { + links: { + related: { + href: 'https://api.test.osf.io/v2/institutions/cos/', + meta: {}, + }, + }, + data: { + id: 'cos', + type: 'institutions', + }, + }, + }, + links: {}, + }, + { + id: 'f118c80b5ccd5402887ec511bb423f6dc6ccca385ee1cfd7accb27429b1a374b', + type: 'institution-users', + attributes: { + report_yearmonth: '2025-06', + user_name: 'QA Runscope', + department: null, + orcid_id: null, + month_last_login: '2025-06', + month_last_active: '2025-06', + account_creation_date: '2018-09', + public_projects: 1, + private_projects: 190, + public_registration_count: 1, + embargoed_registration_count: 0, + published_preprint_count: 973, + public_file_count: 1003, + storage_byte_count: 119760397, + contacts: [], + }, + relationships: { + user: { + links: { + related: { + href: 'https://api.test.osf.io/v2/users/9p57h/', + meta: {}, + }, + }, + data: { + id: '9p57h', + type: 'users', + }, + }, + institution: { + links: { + related: { + href: 'https://api.test.osf.io/v2/institutions/cos/', + meta: {}, + }, + }, + data: { + id: 'cos', + type: 'institutions', + }, + }, + }, + links: {}, + }, + { + id: '315523a12fc2283a749f2a49d219cdc82c547c15e5a327162b8328f7bc9b8b04', + type: 'institution-users', + attributes: { + report_yearmonth: '2025-06', + user_name: 'DC Test Emu', + department: null, + orcid_id: null, + month_last_login: '2023-08', + month_last_active: '2023-08', + account_creation_date: '2021-03', + public_projects: 6, + private_projects: 41, + public_registration_count: 15, + embargoed_registration_count: 0, + published_preprint_count: 2, + public_file_count: 38, + storage_byte_count: 96648435, + contacts: [], + }, + relationships: { + user: { + links: { + related: { + href: 'https://api.test.osf.io/v2/users/e3sfk/', + meta: {}, + }, + }, + data: { + id: 'e3sfk', + type: 'users', + }, + }, + institution: { + links: { + related: { + href: 'https://api.test.osf.io/v2/institutions/cos/', + meta: {}, + }, + }, + data: { + id: 'cos', + type: 'institutions', + }, + }, + }, + links: {}, + }, + { + id: '8236e4218a5f9031f60ac0d2838a52ad35495e9c1f22f6d083ebf43f694ff364', + type: 'institution-users', + attributes: { + report_yearmonth: '2025-06', + user_name: 'Nici Product', + department: null, + orcid_id: null, + month_last_login: '2025-02', + month_last_active: '2022-10', + account_creation_date: '2017-12', + public_projects: 9, + private_projects: 6, + public_registration_count: 19, + embargoed_registration_count: 0, + published_preprint_count: 24, + public_file_count: 114, + storage_byte_count: 68132397, + contacts: [], + }, + relationships: { + user: { + links: { + related: { + href: 'https://api.test.osf.io/v2/users/783bw/', + meta: {}, + }, + }, + data: { + id: '783bw', + type: 'users', + }, + }, + institution: { + links: { + related: { + href: 'https://api.test.osf.io/v2/institutions/cos/', + meta: {}, + }, + }, + data: { + id: 'cos', + type: 'institutions', + }, + }, + }, + links: {}, + }, + { + id: '2850634d59be53903057da707177a1ae34ee22a19e32308bfa2e62ad5664a2b1', + type: 'institution-users', + attributes: { + report_yearmonth: '2025-06', + user_name: 'Eric Test', + department: null, + orcid_id: null, + month_last_login: '2021-10', + month_last_active: '2021-10', + account_creation_date: '2021-10', + public_projects: 1, + private_projects: 4, + public_registration_count: 6, + embargoed_registration_count: 0, + published_preprint_count: 3, + public_file_count: 15, + storage_byte_count: 66493398, + contacts: [], + }, + relationships: { + user: { + links: { + related: { + href: 'https://api.test.osf.io/v2/users/ahe9k/', + meta: {}, + }, + }, + data: { + id: 'ahe9k', + type: 'users', + }, + }, + institution: { + links: { + related: { + href: 'https://api.test.osf.io/v2/institutions/cos/', + meta: {}, + }, + }, + data: { + id: 'cos', + type: 'institutions', + }, + }, + }, + links: {}, + }, + ], + meta: { + total: 173, + per_page: 10, + version: '2.20', + }, + links: { + self: 'https://api.test.osf.io/v2/institutions/cos/metrics/users/?size=1', + first: null, + last: 'https://api.test.osf.io/v2/institutions/cos/metrics/users/?page=18&size=1', + prev: null, + next: 'https://api.test.osf.io/v2/institutions/cos/metrics/users/?page=2&size=1', + }, +}; 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 55cf51fb6..06cea18c0 100644 --- a/src/app/features/admin-institutions/store/institutions-admin.actions.ts +++ b/src/app/features/admin-institutions/store/institutions-admin.actions.ts @@ -27,11 +27,24 @@ export class FetchStorageRegionSearch { constructor(public institutionId: string) {} } -export class SetSelectedInstitutionId { - static readonly type = '[InstitutionsAdmin] Set Selected Institution Id'; - constructor(public institutionId: string) {} +export class FetchInstitutionUsers { + static readonly type = '[InstitutionsAdmin] Fetch Institution Users'; + constructor( + public institutionId: string, + public page = 1, + public pageSize = 10, + public sort = 'user_name', + public filters?: Record + ) {} } -export class ClearInstitutionsAdminData { - static readonly type = '[InstitutionsAdmin] Clear Data'; +export class SendUserMessage { + static readonly type = '[InstitutionsAdmin] Send User Message'; + constructor( + public userId: string, + public institutionId: string, + public messageText: string, + public bccSender: boolean, + public replyTo: boolean + ) {} } 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 5c30eb2dd..472df7cb3 100644 --- a/src/app/features/admin-institutions/store/institutions-admin.model.ts +++ b/src/app/features/admin-institutions/store/institutions-admin.model.ts @@ -1,6 +1,12 @@ -import { AsyncStateModel } from '@shared/models'; +import { AsyncStateModel, AsyncStateWithTotalCount } from '@shared/models'; -import { InstitutionDepartment, InstitutionSearchFilter, InstitutionSummaryMetrics } from '../models'; +import { + InstitutionDepartment, + InstitutionSearchFilter, + InstitutionSummaryMetrics, + InstitutionUser, + SendMessageResponseJsonApi, +} from '../models'; export interface InstitutionsAdminModel { departments: AsyncStateModel; @@ -8,6 +14,8 @@ export interface InstitutionsAdminModel { hasOsfAddonSearch: AsyncStateModel; storageRegionSearch: AsyncStateModel; searchResults: AsyncStateModel; + users: AsyncStateWithTotalCount; + sendMessage: AsyncStateModel; selectedInstitutionId: string | null; currentSearchPropertyPath: string | 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 89a4558c4..07ec7e9ef 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,12 @@ import { Selector } from '@ngxs/store'; -import { InstitutionDepartment, InstitutionSearchFilter, InstitutionSummaryMetrics } from '../models'; +import { + InstitutionDepartment, + InstitutionSearchFilter, + InstitutionSummaryMetrics, + InstitutionUser, + SendMessageResponseJsonApi, +} from '../models'; import { InstitutionsAdminModel } from './institutions-admin.model'; import { InstitutionsAdminState } from './institutions-admin.state'; @@ -90,4 +96,39 @@ export class InstitutionsAdminSelectors { static getCurrentSearchPropertyPath(state: InstitutionsAdminModel): string | null { return state.currentSearchPropertyPath; } + + @Selector([InstitutionsAdminState]) + static getUsers(state: InstitutionsAdminModel): InstitutionUser[] { + return state.users.data; + } + + @Selector([InstitutionsAdminState]) + static getUsersLoading(state: InstitutionsAdminModel): boolean { + 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; + } + + @Selector([InstitutionsAdminState]) + static getSendMessageResponse(state: InstitutionsAdminModel): SendMessageResponseJsonApi | null { + return state.sendMessage.data; + } + + @Selector([InstitutionsAdminState]) + static getSendMessageLoading(state: InstitutionsAdminModel): boolean { + return state.sendMessage.isLoading; + } + + @Selector([InstitutionsAdminState]) + static getSendMessageError(state: InstitutionsAdminModel): string | null { + return state.sendMessage.error; + } } 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 016e75b8c..478ad1072 100644 --- a/src/app/features/admin-institutions/store/institutions-admin.state.ts +++ b/src/app/features/admin-institutions/store/institutions-admin.state.ts @@ -1,9 +1,11 @@ import { Action, State, StateContext } from '@ngxs/store'; -import { catchError, tap, throwError } from 'rxjs'; +import { catchError, tap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { handleSectionError } from '@core/handlers'; + import { InstitutionSummaryMetrics } from '../models'; import { InstitutionsAdminService } from '../services/institutions-admin.service'; @@ -12,7 +14,9 @@ import { FetchInstitutionDepartments, FetchInstitutionSearchResults, FetchInstitutionSummaryMetrics, + FetchInstitutionUsers, FetchStorageRegionSearch, + SendUserMessage, } from './institutions-admin.actions'; import { InstitutionsAdminModel } from './institutions-admin.model'; @@ -24,6 +28,8 @@ import { InstitutionsAdminModel } from './institutions-admin.model'; hasOsfAddonSearch: { data: [], isLoading: false, error: null }, storageRegionSearch: { data: [], isLoading: false, error: null }, searchResults: { data: [], isLoading: false, error: null }, + users: { data: [], totalCount: 0, isLoading: false, error: null }, + sendMessage: { data: null, isLoading: false, error: null }, selectedInstitutionId: null, currentSearchPropertyPath: null, }, @@ -45,16 +51,8 @@ export class InstitutionsAdminState { departments: { data: response, isLoading: false, error: null }, }); }), - catchError((error) => { - ctx.patchState({ - departments: { - ...state.departments, - isLoading: false, - error: error.message, - }, - }); - return throwError(() => error); - }) + + catchError((error) => handleSectionError(ctx, 'departments', error)) ); } @@ -71,16 +69,7 @@ export class InstitutionsAdminState { summaryMetrics: { data: response, isLoading: false, error: null }, }); }), - catchError((error) => { - ctx.patchState({ - summaryMetrics: { - ...state.summaryMetrics, - isLoading: false, - error: error.message, - }, - }); - return throwError(() => error); - }) + catchError((error) => handleSectionError(ctx, 'summaryMetrics', error)) ); } @@ -100,16 +89,7 @@ export class InstitutionsAdminState { searchResults: { data: response, isLoading: false, error: null }, }); }), - catchError((error) => { - ctx.patchState({ - searchResults: { - ...state.searchResults, - isLoading: false, - error: error.message, - }, - }); - return throwError(() => error); - }) + catchError((error) => handleSectionError(ctx, 'searchResults', error)) ); } @@ -126,16 +106,7 @@ export class InstitutionsAdminState { hasOsfAddonSearch: { data: response, isLoading: false, error: null }, }); }), - catchError((error) => { - ctx.patchState({ - hasOsfAddonSearch: { - ...state.hasOsfAddonSearch, - isLoading: false, - error: error.message, - }, - }); - return throwError(() => error); - }) + catchError((error) => handleSectionError(ctx, 'hasOsfAddonSearch', error)) ); } @@ -152,16 +123,51 @@ export class InstitutionsAdminState { storageRegionSearch: { data: response, isLoading: false, error: null }, }); }), - catchError((error) => { - ctx.patchState({ - storageRegionSearch: { - ...state.storageRegionSearch, - isLoading: false, - error: error.message, - }, - }); - return throwError(() => error); - }) + catchError((error) => handleSectionError(ctx, 'storageRegionSearch', error)) ); } + + @Action(FetchInstitutionUsers) + fetchUsers(ctx: StateContext, action: FetchInstitutionUsers) { + const state = ctx.getState(); + ctx.patchState({ + users: { ...state.users, isLoading: true, error: null }, + }); + + return this.institutionsAdminService + .fetchUsers(action.institutionId, action.page, action.pageSize, action.sort, action.filters) + .pipe( + tap((response) => { + ctx.patchState({ + users: { data: response.users, totalCount: response.totalCount, isLoading: false, error: null }, + }); + }), + catchError((error) => handleSectionError(ctx, 'users', error)) + ); + } + + @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, + institutionId: action.institutionId, + messageText: action.messageText, + bccSender: action.bccSender, + replyTo: action.replyTo, + }) + .pipe( + tap((response) => { + ctx.patchState({ + sendMessage: { data: response, isLoading: false, error: null }, + }); + }), + catchError((error) => handleSectionError(ctx, 'sendMessage', error)) + ); + } } diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 7624a053a..665d04ee1 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -8,6 +8,7 @@ "addMore": "Add More", "cancel": "Cancel", "save": "Save", + "send": "Send", "create": "Create", "close": "Close", "download": "Download", @@ -1786,7 +1787,29 @@ "totalPublicFileCount": "Total Public File Count", "totalStorageInGb": "Total Storage in GB", "embargoedRegistrations": "Embargoed registrations", - "privateProjects": "Private projects" + "privateProjects": "Private projects", + "publicProjects": "Public Projects", + "publicRegistrations": "Public Registrations" + }, + "institutionUsers": { + "allDepartments": "All departments", + "lastLogin": "Last Login", + "lastActive": "Last Active", + "accountCreated": "Account Created", + "publishedPreprints": "Published Preprints", + "publicFiles": "Public Files", + "storageBytes": "Storage (Bytes)", + "contacts": "Contacts", + "customize": "Customize", + "sendEmail": "Send Email", + "writeEmailText": "Please write the text of your email here", + "sincerelyYours": "Sincerely Yours", + "ccSender": "CC sender", + "allowReplyToSenderAddress": "Allow reply to sender address", + "hasOrcid": "Has ORCID", + "sendMessage": "Send message", + "osfLink": "OSF Link", + "orcid": "ORCID" } } } diff --git a/src/assets/styles/overrides/multiselect.scss b/src/assets/styles/overrides/multiselect.scss index e8a5d1990..7ba72a902 100644 --- a/src/assets/styles/overrides/multiselect.scss +++ b/src/assets/styles/overrides/multiselect.scss @@ -1,11 +1,20 @@ -.p-multiselect-header { - .p-inputtext { - --p-inputtext-padding-y: 0.4rem; +.p-multiselect { + font-size: 1rem; + --p-multiselect-padding-y: 0.6rem; + --p-multiselect-hover-border-color: var(--pr-blue-1); + --p-multiselect-overlay-color: var(--dark-blue-1); + --p-multiselect-overlay-border-color: var(--grey-2); + --p-multiselect-dropdown-color: var(--grey-1); + + .p-multiselect-header { + .p-inputtext { + --p-inputtext-padding-y: 0.4rem; + } } -} -.p-multiselect-option { - span { - white-space: wrap; + .p-multiselect-option { + span { + white-space: wrap; + } } }