diff --git a/src/app/features/home/constants/dashboard-products.constants.ts b/src/app/features/home/constants/dashboard-products.constants.ts
index 75ddab13e..aad039ae5 100644
--- a/src/app/features/home/constants/dashboard-products.constants.ts
+++ b/src/app/features/home/constants/dashboard-products.constants.ts
@@ -1,13 +1,6 @@
import { DashboardProductLink } from '../models/dashboard-product-link.model';
export const DASHBOARD_PRODUCT_LINKS: DashboardProductLink[] = [
- {
- link: 'https://www.cos.io/products/osf-collections',
- imageSrc: 'assets/images/dashboard/products/osf-collections.png',
- altKey: 'home.loggedIn.dashboard.images.osfCollectionsImageAltText',
- external: true,
- testId: 'products-collections',
- },
{
link: '/institutions',
imageSrc: 'assets/images/dashboard/products/osf-institutions.png',
diff --git a/src/app/features/home/models/dashboard-product-link.model.ts b/src/app/features/home/models/dashboard-product-link.model.ts
index 3f40c022c..d3997158b 100644
--- a/src/app/features/home/models/dashboard-product-link.model.ts
+++ b/src/app/features/home/models/dashboard-product-link.model.ts
@@ -2,6 +2,4 @@ export interface DashboardProductLink {
imageSrc: string;
altKey: string;
link: string;
- external?: boolean;
- testId?: string;
}
diff --git a/src/app/features/home/pages/dashboard/dashboard.component.html b/src/app/features/home/pages/dashboard/dashboard.component.html
index 9a5645c48..e7791102c 100644
--- a/src/app/features/home/pages/dashboard/dashboard.component.html
+++ b/src/app/features/home/pages/dashboard/dashboard.component.html
@@ -84,20 +84,9 @@
{{ 'home.loggedIn.latestResearch.title' | translate }}
@for (product of dashboardProducts; track product.imageSrc) {
- @if (product.external) {
-
-
-
- } @else {
-
-
-
- }
+
+
+
}
}
diff --git a/src/app/features/home/pages/dashboard/dashboard.component.spec.ts b/src/app/features/home/pages/dashboard/dashboard.component.spec.ts
index 7e207830a..bbc04c67f 100644
--- a/src/app/features/home/pages/dashboard/dashboard.component.spec.ts
+++ b/src/app/features/home/pages/dashboard/dashboard.component.spec.ts
@@ -2,13 +2,16 @@ import { Store } from '@ngxs/store';
import { MockComponents, MockProvider } from 'ng-mocks';
+import { SortEvent } from 'primeng/api';
+import { TablePageEvent } from 'primeng/table';
+
import { Subject } from 'rxjs';
import { Mock } from 'vitest';
import { PLATFORM_ID } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { ActivatedRoute, Router } from '@angular/router';
+import { ActivatedRoute, provideRouter, Router } from '@angular/router';
import { ScheduledBannerComponent } from '@core/components/osf-banners/scheduled-banner/scheduled-banner.component';
import { UserSelectors } from '@osf/core/store/user';
@@ -19,13 +22,20 @@ import { SearchInputComponent } from '@osf/shared/components/search-input/search
import { SubHeaderComponent } from '@osf/shared/components/sub-header/sub-header.component';
import { FEATURE_FLAGS } from '@osf/shared/constants/feature-flags.const';
import { SortOrder } from '@osf/shared/enums/sort-order.enum';
-import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources-item.model';
import { CustomDialogService } from '@osf/shared/services/custom-dialog.service';
import { ProjectRedirectDialogService } from '@osf/shared/services/project-redirect-dialog.service';
import { ClearMyResources, GetMyProjects, MyResourcesSelectors } from '@osf/shared/stores/my-resources';
+import {
+ MOCK_MY_RESOURCES_ITEM_PROJECT,
+ MOCK_MY_RESOURCES_ITEM_PROJECT_PRIVATE,
+} from '@testing/mocks/my-resources.mock';
import { provideOSFCore } from '@testing/osf.testing.provider';
import { CustomDialogServiceMock, CustomDialogServiceMockType } from '@testing/providers/custom-dialog-provider.mock';
+import {
+ ProjectRedirectDialogServiceMock,
+ ProjectRedirectDialogServiceMockType,
+} from '@testing/providers/project-redirect-dialog.service.mock';
import { ActivatedRouteMockBuilder } from '@testing/providers/route-provider.mock';
import { RouterMockBuilder, RouterMockType } from '@testing/providers/router-provider.mock';
import {
@@ -36,7 +46,6 @@ import {
} from '@testing/providers/store-provider.mock';
import { WorkflowLauncherSectionComponent } from '../../components/workflow-launcher-section/workflow-launcher-section.component';
-import { DASHBOARD_PRODUCT_LINKS } from '../../constants/dashboard-products.constants';
import { DashboardComponent } from './dashboard.component';
@@ -46,27 +55,10 @@ describe('DashboardComponent', () => {
let store: Store;
let routerMock: RouterMockType;
let customDialogService: CustomDialogServiceMockType;
- let projectRedirectDialogService: { showProjectRedirectDialog: Mock };
-
- const projectItem: MyResourcesItem = {
- id: '1',
- type: 'nodes',
- title: 'Alpha project',
- dateCreated: '2024-01-01',
- dateModified: '2024-01-02',
- isPublic: true,
- contributors: [],
- };
-
- const secondProjectItem: MyResourcesItem = {
- id: '2',
- type: 'nodes',
- title: 'Beta project',
- dateCreated: '2024-01-03',
- dateModified: '2024-01-04',
- isPublic: false,
- contributors: [],
- };
+ let projectRedirectDialogService: ProjectRedirectDialogServiceMockType;
+
+ const projectItem = MOCK_MY_RESOURCES_ITEM_PROJECT;
+ const secondProjectItem = MOCK_MY_RESOURCES_ITEM_PROJECT_PRIVATE;
const defaultSignals: SignalOverride[] = [
{ selector: MyResourcesSelectors.getProjects, value: [] },
@@ -84,7 +76,7 @@ describe('DashboardComponent', () => {
function setup(options: SetupOverrides = {}) {
routerMock = RouterMockBuilder.create().build();
customDialogService = CustomDialogServiceMock.simple();
- projectRedirectDialogService = { showProjectRedirectDialog: vi.fn() };
+ projectRedirectDialogService = ProjectRedirectDialogServiceMock.simple();
const routeMock = ActivatedRouteMockBuilder.create()
.withQueryParams(options.routeQueryParams ?? {})
.build();
@@ -103,11 +95,12 @@ describe('DashboardComponent', () => {
],
providers: [
provideOSFCore(),
+ provideRouter([]),
MockProvider(ActivatedRoute, routeMock),
MockProvider(Router, routerMock),
MockProvider(CustomDialogService, customDialogService),
MockProvider(ProjectRedirectDialogService, projectRedirectDialogService),
- MockProvider(PLATFORM_ID, options?.platformId ?? 'browser'),
+ MockProvider(PLATFORM_ID, options.platformId ?? 'browser'),
provideMockStore({ signals: mergeSignalOverrides(defaultSignals, options.selectorOverrides) }),
],
});
@@ -118,6 +111,10 @@ describe('DashboardComponent', () => {
fixture.detectChanges();
}
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
it('should create and fetch projects with default params on init', () => {
setup();
@@ -132,14 +129,6 @@ describe('DashboardComponent', () => {
);
});
- it('should disable project creation and show tooltip when isProjectCreationDisabled is true', () => {
- setup({
- selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }],
- });
-
- expect(component.buttonTooltip()).toBe('home.loggedIn.dashboard.createProjectDisabledTooltip');
- });
-
it('should read query params and fetch projects on init', () => {
setup({
routeQueryParams: {
@@ -166,12 +155,6 @@ describe('DashboardComponent', () => {
);
});
- it('should sync total projects count into table params', () => {
- setup({ selectorOverrides: [{ selector: MyResourcesSelectors.getTotalProjects, value: 42 }] });
-
- expect(component.tableParams().totalRecords).toBe(42);
- });
-
it('should default sort order when query param sort order is invalid', () => {
setup({
routeQueryParams: {
@@ -183,20 +166,26 @@ describe('DashboardComponent', () => {
expect(component.sortOrder()).toBe(SortOrder.Asc);
});
- it('should use dashboard sub header when projects exist', () => {
+ it('should sync total projects count into table params', () => {
+ setup({ selectorOverrides: [{ selector: MyResourcesSelectors.getTotalProjects, value: 42 }] });
+
+ expect(component.tableParams().totalRecords).toBe(42);
+ });
+
+ it('should disable project creation messaging when isProjectCreationDisabled is true', () => {
setup({
- selectorOverrides: [{ selector: MyResourcesSelectors.getProjects, value: [projectItem] }],
+ selectorOverrides: [{ selector: UserSelectors.isProjectCreationDisabled, value: true }],
});
- expect(component.existsProjects()).toBeTruthy();
- expect(component.subHeaderTitle()).toBe('home.loggedIn.dashboard.title');
+ expect(component.buttonTooltip()).toBe('home.loggedIn.dashboard.createProjectDisabledTooltip');
+ expect(component.noProjectsMessage()).toBe('home.loggedIn.dashboard.noCreatedProjectAndCreateProjectDisabled');
});
it('should update query params on page change', () => {
setup();
(routerMock.navigate as Mock).mockClear();
- component.onPageChange({ first: 20, rows: 10 } as never);
+ component.onPageChange({ first: 20, rows: 10 } as TablePageEvent);
expect(routerMock.navigate).toHaveBeenCalledWith([], {
relativeTo: TestBed.inject(ActivatedRoute),
@@ -215,10 +204,10 @@ describe('DashboardComponent', () => {
setup();
(routerMock.navigate as Mock).mockClear();
- component.onSort({ field: 'dateModified', order: -1 } as never);
+ component.onSort({ field: 'dateModified', order: SortOrder.Desc } as SortEvent);
expect(component.sortColumn()).toBe('dateModified');
- expect(component.sortOrder()).toBe(-1);
+ expect(component.sortOrder()).toBe(SortOrder.Desc);
expect(routerMock.navigate).toHaveBeenCalledWith([], {
relativeTo: TestBed.inject(ActivatedRoute),
queryParams: {
@@ -226,7 +215,7 @@ describe('DashboardComponent', () => {
rows: 10,
search: undefined,
sortField: 'dateModified',
- sortOrder: -1,
+ sortOrder: SortOrder.Desc,
},
queryParamsHandling: 'merge',
});
@@ -236,85 +225,85 @@ describe('DashboardComponent', () => {
setup();
(routerMock.navigate as Mock).mockClear();
- component.onSort({ field: undefined, order: SortOrder.Desc } as never);
+ component.onSort({ field: undefined, order: SortOrder.Desc } as SortEvent);
expect(routerMock.navigate).not.toHaveBeenCalled();
});
- it('should dispatch fetch projects with current search and sort state', () => {
+ it('should update query params after search debounce', () => {
+ vi.useFakeTimers();
setup();
- (store.dispatch as Mock).mockClear();
-
- component.searchControl.setValue('alp');
- component.sortColumn.set('title');
- component.sortOrder.set(-1);
-
- component.fetchProjects();
+ (routerMock.navigate as Mock).mockClear();
- expect(store.dispatch).toHaveBeenCalledWith(
- new GetMyProjects(1, 10, {
- searchValue: 'alp',
- searchFields: ['title'],
- sortColumn: 'title',
- sortOrder: -1,
- })
- );
- });
+ component.searchControl.setValue('alpha');
+ vi.advanceTimersByTime(300);
- it('should filter projects by search value', () => {
- setup({
- routeQueryParams: { search: 'alp' },
- selectorOverrides: [
- {
- selector: MyResourcesSelectors.getProjects,
- value: [projectItem, secondProjectItem],
- },
- ],
+ expect(routerMock.navigate).toHaveBeenCalledWith([], {
+ relativeTo: TestBed.inject(ActivatedRoute),
+ queryParams: {
+ page: 1,
+ rows: 10,
+ search: 'alpha',
+ sortField: undefined,
+ sortOrder: 1,
+ },
+ queryParamsHandling: 'merge',
});
-
- expect(component.filteredProjects()).toEqual([projectItem]);
- });
-
- it('should treat search value as existing projects when list is empty', () => {
- setup({ routeQueryParams: { search: 'query' } });
-
- expect(component.existsProjects()).toBeTruthy();
- expect(component.subHeaderTitle()).toBe('home.loggedIn.dashboard.title');
});
- it('should use welcome sub header when no projects and no search', () => {
+ it('should not update query params when debounced search value is unchanged', () => {
+ vi.useFakeTimers();
setup();
+ component.searchControl.setValue('alpha');
+ vi.advanceTimersByTime(300);
+ (routerMock.navigate as Mock).mockClear();
- expect(component.existsProjects()).toBe(false);
- expect(component.subHeaderTitle()).toBe('home.loggedIn.dashboard.welcome');
- });
-
- it('should expose dashboard product links', () => {
- setup();
+ component.searchControl.setValue('alpha');
+ vi.advanceTimersByTime(300);
- expect(component.dashboardProducts).toBe(DASHBOARD_PRODUCT_LINKS);
+ expect(routerMock.navigate).not.toHaveBeenCalled();
});
- it('should update query params after search debounce', () => {
- vi.useFakeTimers();
+ it('should omit empty search from query params', () => {
setup();
(routerMock.navigate as Mock).mockClear();
- component.searchControl.setValue('alpha');
- vi.advanceTimersByTime(300);
+ component.searchControl.setValue('');
+ component.updateQueryParams();
expect(routerMock.navigate).toHaveBeenCalledWith([], {
relativeTo: TestBed.inject(ActivatedRoute),
queryParams: {
page: 1,
rows: 10,
- search: 'alpha',
+ search: undefined,
sortField: undefined,
sortOrder: 1,
},
queryParamsHandling: 'merge',
});
- vi.useRealTimers();
+ });
+
+ it('should filter projects by search value case insensitively', () => {
+ setup({
+ routeQueryParams: { search: 'PRIVATE' },
+ selectorOverrides: [
+ {
+ selector: MyResourcesSelectors.getProjects,
+ value: [projectItem, secondProjectItem],
+ },
+ ],
+ });
+
+ expect(component.filteredProjects()).toEqual([secondProjectItem]);
+ });
+
+ it('should return all projects when search is empty', () => {
+ setup({
+ selectorOverrides: [{ selector: MyResourcesSelectors.getProjects, value: [projectItem, secondProjectItem] }],
+ });
+
+ expect(component.filteredProjects()).toEqual([projectItem, secondProjectItem]);
});
it('should navigate to project and set active project', () => {
@@ -343,12 +332,14 @@ describe('DashboardComponent', () => {
it('should not redirect when create project dialog closes without project id', () => {
setup();
- const onClose$ = new Subject();
+ const onClose$ = new Subject<{ project?: { id?: string } } | null>();
customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$.asObservable()));
component.createProject();
onClose$.next(null);
+ expect(projectRedirectDialogService.showProjectRedirectDialog).not.toHaveBeenCalled();
+ onClose$.next({ project: {} });
expect(projectRedirectDialogService.showProjectRedirectDialog).not.toHaveBeenCalled();
});
@@ -402,100 +393,31 @@ describe('DashboardComponent', () => {
expect(fixture.nativeElement.querySelector('osf-workflow-launcher-section')).toBeFalsy();
});
- it('should return all projects when search is empty', () => {
- setup({
- selectorOverrides: [{ selector: MyResourcesSelectors.getProjects, value: [projectItem, secondProjectItem] }],
- });
-
- expect(component.filteredProjects()).toEqual([projectItem, secondProjectItem]);
- });
-
- it('should filter projects case insensitively', () => {
- setup({
- routeQueryParams: { search: 'ALPHA' },
- selectorOverrides: [{ selector: MyResourcesSelectors.getProjects, value: [projectItem, secondProjectItem] }],
- });
-
- expect(component.filteredProjects()).toEqual([projectItem]);
- });
-
- it('should omit empty search from query params', () => {
+ it('should use welcome state when no projects and no search', () => {
setup();
- (routerMock.navigate as Mock).mockClear();
-
- component.searchControl.setValue('');
- component.updateQueryParams();
- expect(routerMock.navigate).toHaveBeenCalledWith([], {
- relativeTo: TestBed.inject(ActivatedRoute),
- queryParams: {
- page: 1,
- rows: 10,
- search: undefined,
- sortField: undefined,
- sortOrder: 1,
- },
- queryParamsHandling: 'merge',
- });
- });
-
- it('should not update query params when debounced search value is unchanged', () => {
- vi.useFakeTimers();
- setup();
- component.searchControl.setValue('alpha');
- vi.advanceTimersByTime(300);
- (routerMock.navigate as Mock).mockClear();
-
- component.searchControl.setValue('alpha');
- vi.advanceTimersByTime(300);
-
- expect(routerMock.navigate).not.toHaveBeenCalled();
- vi.useRealTimers();
+ expect(component.existsProjects()).toBe(false);
+ expect(component.subHeaderTitle()).toBe('home.loggedIn.dashboard.welcome');
+ expect(component.noProjectsMessage()).toBe('home.loggedIn.dashboard.noCreatedProject');
+ expect(fixture.nativeElement.querySelector('osf-my-projects-table')).toBeFalsy();
+ expect(fixture.nativeElement.querySelector('.latest-research-container')).toBeFalsy();
});
- it('should render empty state when no projects exist', () => {
- setup();
+ it('should treat search value as existing projects when list is empty', () => {
+ setup({ routeQueryParams: { search: 'query' } });
- expect(fixture.nativeElement.querySelector('.connected-record-image')).toBeTruthy();
- expect(fixture.nativeElement.querySelector('osf-my-projects-table')).toBeFalsy();
- expect(fixture.nativeElement.querySelector('.latest-research-container')).toBeFalsy();
+ expect(component.existsProjects()).toBeTruthy();
+ expect(component.subHeaderTitle()).toBe('home.loggedIn.dashboard.title');
});
- it('should render projects table and latest research when projects exist', () => {
+ it('should show projects table when projects exist', () => {
setup({
selectorOverrides: [{ selector: MyResourcesSelectors.getProjects, value: [projectItem] }],
});
+ expect(component.existsProjects()).toBeTruthy();
+ expect(component.subHeaderTitle()).toBe('home.loggedIn.dashboard.title');
expect(fixture.nativeElement.querySelector('osf-my-projects-table')).toBeTruthy();
expect(fixture.nativeElement.querySelector('.latest-research-container')).toBeTruthy();
- expect(fixture.nativeElement.querySelector('.connected-record-image')).toBeFalsy();
- });
-
- it('should render dashboard product links', () => {
- setup();
-
- const productImages = fixture.nativeElement.querySelectorAll('.flex.flex-column.p-4 img');
-
- expect(productImages.length).toBe(DASHBOARD_PRODUCT_LINKS.length);
- });
-
- it('should render external product link with blank target', () => {
- setup();
-
- const externalLink = fixture.nativeElement.querySelector('[data-test-products-collections]');
-
- expect(externalLink?.getAttribute('target')).toBe('_blank');
- expect(externalLink?.getAttribute('rel')).toBe('noopener noreferrer');
- });
-
- it('should not redirect when create project dialog closes with missing project id', () => {
- setup();
- const onClose$ = new Subject<{ project: { id?: string } }>();
- customDialogService.open.mockReturnValue(CustomDialogServiceMock.dialogRefWithClose(onClose$.asObservable()));
-
- component.createProject();
- onClose$.next({ project: {} });
-
- expect(projectRedirectDialogService.showProjectRedirectDialog).not.toHaveBeenCalled();
});
});
diff --git a/src/app/features/home/pages/dashboard/dashboard.component.ts b/src/app/features/home/pages/dashboard/dashboard.component.ts
index 0e8a1730b..0eda1d14e 100644
--- a/src/app/features/home/pages/dashboard/dashboard.component.ts
+++ b/src/app/features/home/pages/dashboard/dashboard.component.ts
@@ -9,7 +9,17 @@ import { TablePageEvent } from 'primeng/table';
import { debounceTime, distinctUntilChanged, filter, tap } from 'rxjs';
import { isPlatformBrowser, NgOptimizedImage } from '@angular/common';
-import { Component, computed, DestroyRef, effect, inject, OnInit, PLATFORM_ID, signal } from '@angular/core';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ computed,
+ DestroyRef,
+ effect,
+ inject,
+ OnInit,
+ PLATFORM_ID,
+ signal,
+} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormControl } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
@@ -50,6 +60,7 @@ import { DASHBOARD_PRODUCT_LINKS } from '../../constants/dashboard-products.cons
],
templateUrl: './dashboard.component.html',
styleUrl: './dashboard.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DashboardComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
@@ -57,8 +68,7 @@ export class DashboardComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly customDialogService = inject(CustomDialogService);
private readonly projectRedirectDialogService = inject(ProjectRedirectDialogService);
- private readonly platformId = inject(PLATFORM_ID);
- private readonly isBrowser = isPlatformBrowser(this.platformId);
+ private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
readonly searchControl = new FormControl('');
readonly activeProject = signal(null);
@@ -83,17 +93,16 @@ export class DashboardComponent implements OnInit {
return this.projects().filter((project) => project.title.toLowerCase().includes(search));
});
- readonly buttonTooltip = computed(() => {
- return this.projectCreationDisabled() ? 'home.loggedIn.dashboard.createProjectDisabledTooltip' : '';
- });
-
readonly existsProjects = computed(() => this.projects().length || !!this.searchControl.value?.length);
- readonly noProjectsMessage = computed(() => {
- if (this.projectCreationDisabled()) {
- return 'home.loggedIn.dashboard.noCreatedProjectAndCreateProjectDisabled';
- }
- return 'home.loggedIn.dashboard.noCreatedProject';
- });
+
+ readonly buttonTooltip = computed(() =>
+ this.projectCreationDisabled() ? 'home.loggedIn.dashboard.createProjectDisabledTooltip' : ''
+ );
+ readonly noProjectsMessage = computed(() =>
+ this.projectCreationDisabled()
+ ? 'home.loggedIn.dashboard.noCreatedProjectAndCreateProjectDisabled'
+ : 'home.loggedIn.dashboard.noCreatedProject'
+ );
readonly subHeaderTitle = computed(() =>
this.existsProjects() ? 'home.loggedIn.dashboard.title' : 'home.loggedIn.dashboard.welcome'
);
@@ -215,7 +224,7 @@ export class DashboardComponent implements OnInit {
this.customDialogService
.open(CreateProjectDialogComponent, { header: 'myProjects.header.createProject', width: '850px' })
.onClose.pipe(
- filter((result) => result?.project.id),
+ filter((result: { project: MyResourcesItem }) => !!result?.project.id),
tap((result) => this.projectRedirectDialogService.showProjectRedirectDialog(result.project.id)),
takeUntilDestroyed(this.destroyRef)
)
diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json
index d4e7e7ad4..d75329824 100644
--- a/src/assets/i18n/en.json
+++ b/src/assets/i18n/en.json
@@ -807,7 +807,6 @@
"createProjectDisabledTooltip": "Project creation is no longer available. OSF is transitioning away from Projects as part of a platform update. Your existing projects remain accessible.",
"getStartedHelp": "Visit Get Started Help Guides",
"images": {
- "osfCollectionsImageAltText": "OSF Collections",
"osfInstitutionsImageAltText": "OSF Institutions",
"osfPreprintsImageAltTest": "OSF Preprints",
"osfRegistriesImageAltTest": "OSF Registries"
diff --git a/src/assets/images/dashboard/products/osf-collections.png b/src/assets/images/dashboard/products/osf-collections.png
deleted file mode 100644
index c85939d6e..000000000
Binary files a/src/assets/images/dashboard/products/osf-collections.png and /dev/null differ