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