From e00192d727bda7a5572b8682cb5340d6a49d8468 Mon Sep 17 00:00:00 2001 From: nsemets Date: Thu, 13 Nov 2025 12:10:45 +0200 Subject: [PATCH 1/5] fix(state error handling): updated state error handling --- .../shared/stores/subjects/subjects.state.ts | 22 +++------- src/app/shared/stores/wiki/wiki.state.ts | 40 ++++++------------- 2 files changed, 18 insertions(+), 44 deletions(-) diff --git a/src/app/shared/stores/subjects/subjects.state.ts b/src/app/shared/stores/subjects/subjects.state.ts index 6dd296910..9965aee72 100644 --- a/src/app/shared/stores/subjects/subjects.state.ts +++ b/src/app/shared/stores/subjects/subjects.state.ts @@ -1,9 +1,10 @@ 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 '@osf/shared/helpers/state-error.handler'; import { SubjectModel } from '@osf/shared/models/subject/subject.model'; import { SubjectsService } from '@osf/shared/services/subjects.service'; @@ -62,7 +63,7 @@ export class SubjectsState { }); } }), - catchError((error) => this.handleError(ctx, 'subjects', error)) + catchError((error) => handleSectionError(ctx, search ? 'searchedSubjects' : 'subjects', error)) ); } @@ -88,7 +89,7 @@ export class SubjectsState { }, }); }), - catchError((error) => this.handleError(ctx, 'subjects', error)) + catchError((error) => handleSectionError(ctx, 'subjects', error)) ); } @@ -116,7 +117,7 @@ export class SubjectsState { }, }); }), - catchError((error) => this.handleError(ctx, 'selectedSubjects', error)) + catchError((error) => handleSectionError(ctx, 'selectedSubjects', error)) ); } @@ -147,7 +148,7 @@ export class SubjectsState { }, }); }), - catchError((error) => this.handleError(ctx, 'selectedSubjects', error)) + catchError((error) => handleSectionError(ctx, 'selectedSubjects', error)) ); } @@ -171,15 +172,4 @@ export class SubjectsState { return subject; }); } - - private handleError(ctx: StateContext, section: keyof SubjectsModel, error: Error) { - ctx.patchState({ - [section]: { - ...ctx.getState()[section], - isLoading: false, - error: error.message, - }, - }); - return throwError(() => error); - } } diff --git a/src/app/shared/stores/wiki/wiki.state.ts b/src/app/shared/stores/wiki/wiki.state.ts index 6c0b109ca..050308677 100644 --- a/src/app/shared/stores/wiki/wiki.state.ts +++ b/src/app/shared/stores/wiki/wiki.state.ts @@ -1,9 +1,10 @@ import { Action, State, StateContext } from '@ngxs/store'; -import { catchError, map, tap, throwError } from 'rxjs'; +import { catchError, map, tap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; import { WikiService } from '@osf/shared/services/wiki.service'; import { @@ -52,7 +53,7 @@ export class WikiState { currentWikiId: wiki.id, }); }), - catchError((error) => this.handleError(ctx, error)) + catchError((error) => handleSectionError(ctx, 'wikiList', error)) ); } @@ -78,7 +79,7 @@ export class WikiState { currentWikiId: updatedWiki.id, }); }), - catchError((error) => this.handleError(ctx, error)) + catchError((error) => handleSectionError(ctx, 'wikiList', error)) ); } @@ -106,7 +107,7 @@ export class WikiState { currentWikiId: updatedList.length > 0 ? updatedList[0].id : '', }); }), - catchError((error) => this.handleError(ctx, error)) + catchError((error) => handleSectionError(ctx, 'wikiList', error)) ); } @@ -131,7 +132,7 @@ export class WikiState { }, }); }), - catchError((error) => this.handleError(ctx, error)) + catchError((error) => handleSectionError(ctx, 'homeWikiContent', error)) ); } @@ -182,7 +183,7 @@ export class WikiState { }); }), map((wiki) => wiki), - catchError((error) => this.handleError(ctx, error)) + catchError((error) => handleSectionError(ctx, 'wikiList', error)) ); } @@ -208,7 +209,7 @@ export class WikiState { }, }); }), - catchError((error) => this.handleError(ctx, error)) + catchError((error) => handleSectionError(ctx, 'componentsWikiList', error)) ); } @@ -233,23 +234,6 @@ export class WikiState { }); } - private handleError(ctx: StateContext, error: Error) { - ctx.patchState({ - homeWikiContent: { - ...ctx.getState().homeWikiContent, - isLoading: false, - error: error.message, - }, - wikiList: { - ...ctx.getState().wikiList, - isLoading: false, - isSubmitting: false, - error: error.message, - }, - }); - return throwError(() => error); - } - @Action(GetWikiVersions) getWikiVersions(ctx: StateContext, action: GetWikiVersions) { const state = ctx.getState(); @@ -271,7 +255,7 @@ export class WikiState { }, }); }), - catchError((error) => this.handleError(ctx, error)) + catchError((error) => handleSectionError(ctx, 'wikiVersions', error)) ); } @@ -292,7 +276,7 @@ export class WikiState { }, }); }), - catchError((error) => this.handleError(ctx, error)) + catchError((error) => handleSectionError(ctx, 'versionContent', error)) ); } @@ -318,7 +302,7 @@ export class WikiState { }, }); }), - catchError((error) => this.handleError(ctx, error)) + catchError((error) => handleSectionError(ctx, 'versionContent', error)) ); } @@ -343,7 +327,7 @@ export class WikiState { }, }); }), - catchError((error) => this.handleError(ctx, error)) + catchError((error) => handleSectionError(ctx, 'compareVersionContent', error)) ); } } From aed86cdc165a6e01c0bf725fae1f62ab92788255 Mon Sep 17 00:00:00 2001 From: nsemets Date: Thu, 13 Nov 2025 13:06:39 +0200 Subject: [PATCH 2/5] fix(linked-resources): added see more logic --- .../component-card.component.html | 2 +- .../link-resource-dialog.component.html | 21 +-- .../link-resource-dialog.component.ts | 34 +++-- .../linked-resources.component.html | 13 +- .../linked-resources.component.ts | 18 ++- src/app/shared/services/node-links.service.ts | 13 +- .../stores/node-links/node-links.actions.ts | 11 ++ .../stores/node-links/node-links.model.ts | 16 +- .../stores/node-links/node-links.selectors.ts | 13 ++ .../stores/node-links/node-links.state.ts | 143 +++++++++++------- 10 files changed, 206 insertions(+), 78 deletions(-) diff --git a/src/app/features/project/overview/components/component-card/component-card.component.html b/src/app/features/project/overview/components/component-card/component-card.component.html index 74e9de3f6..20afd4b13 100644 --- a/src/app/features/project/overview/components/component-card/component-card.component.html +++ b/src/app/features/project/overview/components/component-card/component-card.component.html @@ -35,7 +35,7 @@

} -
+

{{ 'common.labels.contributors' | translate }}:

@@ -84,15 +83,19 @@ {{ item.dateCreated | date: 'MMM d, y' }} {{ item.dateModified | date: 'MMM d, y' }} - @for (contributor of item.contributors; track contributor.id) { + @for (contributor of item.contributors.slice(0, 3); track contributor.id) { {{ contributor.fullName }}{{ $last ? '' : ', ' }} } + @if (item.contributors.length > 3) { + , {{ 'common.labels.and' | translate }} {{ item.contributors.length - 3 }} + {{ 'common.labels.more' | translate }} + } } @else { - + } diff --git a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts index 7c880f723..e3f9cc320 100644 --- a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts +++ b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts @@ -26,12 +26,14 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, FormsModule } from '@angular/forms'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; +import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params.constants'; import { ResourceSearchMode } from '@osf/shared/enums/resource-search-mode.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { GetMyProjects, GetMyRegistrations, MyResourcesSelectors } from '@osf/shared/stores/my-resources'; import { CreateNodeLink, DeleteNodeLink, GetLinkedResources, NodeLinksSelectors } from '@osf/shared/stores/node-links'; import { MyResourcesItem } from '@shared/models/my-resources/my-resources.models'; import { MyResourcesSearchFilters } from '@shared/models/my-resources/my-resources-search-filters.models'; +import { TableParameters } from '@shared/models/table-parameters.model'; import { ProjectOverviewSelectors } from '../../store'; @@ -89,11 +91,17 @@ export class LinkResourceDialogComponent { this.resourceType() === ResourceType.Project ? this.totalProjectsCount() : this.totalRegistrationsCount() ); - isItemLinked = computed(() => { - const linkedResources = this.linkedResources(); - const linkedTargetIds = new Set(linkedResources.map((resource) => resource.id)); + tableParams = computed(() => ({ + ...DEFAULT_TABLE_PARAMS, + rows: this.tableRows, + firstRowIndex: (this.currentPage() - 1) * this.tableRows, + paginator: this.currentTotalCount() > this.tableRows, + totalRecords: this.currentTotalCount(), + })); - return (itemId: string) => linkedTargetIds.has(itemId); + linkedResourceIds = computed(() => { + const linkedResources = this.linkedResources(); + return new Set(linkedResources.map((resource) => resource.id)); }); actions = createDispatchMap({ @@ -112,12 +120,12 @@ export class LinkResourceDialogComponent { onSearchModeChange(mode: ResourceSearchMode): void { this.searchMode.set(mode); - this.currentPage.set(1); + this.resetToFirstPage(); } onObjectTypeChange(type: ResourceType): void { this.resourceType.set(type); - this.currentPage.set(1); + this.resetToFirstPage(); } onPageChange(event: TablePageEvent): void { @@ -126,9 +134,17 @@ export class LinkResourceDialogComponent { this.handleSearch(this.searchControl.value || '', this.searchMode(), this.resourceType()); } + isItemLinked(itemId: string): boolean { + return this.linkedResourceIds().has(itemId); + } + + private resetToFirstPage(): void { + this.currentPage.set(1); + } + setupSearchEffect() { effect(() => { - this.currentPage.set(1); + this.resetToFirstPage(); this.handleSearch(this.searchControl.value || '', this.searchMode(), this.resourceType()); }); } @@ -146,7 +162,7 @@ export class LinkResourceDialogComponent { this.searchControl.valueChanges .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) .subscribe((searchValue) => { - this.currentPage.set(1); + this.resetToFirstPage(); this.handleSearch(searchValue ?? '', this.searchMode(), this.resourceType()); }); } @@ -170,7 +186,7 @@ export class LinkResourceDialogComponent { return; } - const isCurrentlyLinked = this.isItemLinked()(resource.id); + const isCurrentlyLinked = this.isItemLinked(resource.id); if (isCurrentlyLinked) { const resources = this.linkedResources(); diff --git a/src/app/features/project/overview/components/linked-resources/linked-resources.component.html b/src/app/features/project/overview/components/linked-resources/linked-resources.component.html index b02a69622..550c84a5c 100644 --- a/src/app/features/project/overview/components/linked-resources/linked-resources.component.html +++ b/src/app/features/project/overview/components/linked-resources/linked-resources.component.html @@ -21,6 +21,7 @@

{{ linkedResource.title }}

+ @if (canEdit()) {
}
-
+

{{ 'common.labels.contributors' | translate }}:

@@ -53,4 +54,14 @@

} }

+ @if (hasMoreLinkedResources() && !isLinkedResourcesLoading()) { +
+ +
+ }
diff --git a/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts b/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts index eee5c3f0b..4963df808 100644 --- a/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts +++ b/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts @@ -1,4 +1,4 @@ -import { select } from '@ngxs/store'; +import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; @@ -11,8 +11,9 @@ import { ContributorsListComponent } from '@osf/shared/components/contributors-l import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; -import { NodeLinksSelectors } from '@osf/shared/stores/node-links'; +import { LoadMoreLinkedResources, NodeLinksSelectors } from '@osf/shared/stores/node-links'; +import { ProjectOverviewSelectors } from '../../store'; import { DeleteNodeLinkDialogComponent } from '../delete-node-link-dialog/delete-node-link-dialog.component'; import { LinkResourceDialogComponent } from '../link-resource-dialog/link-resource-dialog.component'; @@ -30,11 +31,17 @@ export class LinkedResourcesComponent { linkedResources = select(NodeLinksSelectors.getLinkedResources); isLinkedResourcesLoading = select(NodeLinksSelectors.getLinkedResourcesLoading); + hasMoreLinkedResources = select(NodeLinksSelectors.hasMoreLinkedResources); + isLoadingMoreLinkedResources = select(NodeLinksSelectors.isLoadingMoreLinkedResources); + currentProject = select(ProjectOverviewSelectors.getProject); + + private readonly actions = createDispatchMap({ loadMoreLinkedResources: LoadMoreLinkedResources }); openLinkProjectModal() { this.customDialogService.open(LinkResourceDialogComponent, { header: 'project.overview.dialog.linkProject.header', width: '850px', + closable: false, }); } @@ -49,4 +56,11 @@ export class LinkedResourcesComponent { data: { currentLink }, }); } + + loadMore(): void { + const project = this.currentProject(); + if (!project) return; + + this.actions.loadMoreLinkedResources(project.id); + } } diff --git a/src/app/shared/services/node-links.service.ts b/src/app/shared/services/node-links.service.ts index 3ced22bb2..870e9b7db 100644 --- a/src/app/shared/services/node-links.service.ts +++ b/src/app/shared/services/node-links.service.ts @@ -11,6 +11,7 @@ import { MyResourcesItem } from '../models/my-resources/my-resources.models'; import { NodeLinkJsonApi } from '../models/node-links/node-link-json-api.model'; import { NodeModel } from '../models/nodes/base-node.model'; import { NodesResponseJsonApi } from '../models/nodes/nodes-json-api.model'; +import { PaginatedData } from '../models/paginated-data.model'; import { JsonApiService } from './json-api.service'; @@ -60,25 +61,29 @@ export class NodeLinksService { ); } - fetchLinkedProjects(projectId: string): Observable { + fetchLinkedProjects(projectId: string, page: number, pageSize: number): Observable> { const params: Record = { embed: 'bibliographic_contributors', 'fields[users]': 'family_name,full_name,given_name,middle_name', + page, + 'page[size]': pageSize, }; return this.jsonApiService .get(`${this.apiUrl}/nodes/${projectId}/linked_nodes/`, params) - .pipe(map((response) => BaseNodeMapper.getNodesWithEmbedContributors(response.data))); + .pipe(map((response) => BaseNodeMapper.getNodesWithEmbedsAndTotalData(response))); } - fetchLinkedRegistrations(projectId: string): Observable { + fetchLinkedRegistrations(projectId: string, page: number, pageSize: number): Observable> { const params: Record = { embed: 'bibliographic_contributors', 'fields[users]': 'family_name,full_name,given_name,middle_name', + page, + 'page[size]': pageSize, }; return this.jsonApiService .get(`${this.apiUrl}/nodes/${projectId}/linked_registrations/`, params) - .pipe(map((response) => BaseNodeMapper.getNodesWithEmbedContributors(response.data))); + .pipe(map((response) => BaseNodeMapper.getNodesWithEmbedsAndTotalData(response))); } } diff --git a/src/app/shared/stores/node-links/node-links.actions.ts b/src/app/shared/stores/node-links/node-links.actions.ts index ed3d10145..9515fb1eb 100644 --- a/src/app/shared/stores/node-links/node-links.actions.ts +++ b/src/app/shared/stores/node-links/node-links.actions.ts @@ -1,3 +1,4 @@ +import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params.constants'; import { MyResourcesItem } from '@osf/shared/models/my-resources/my-resources.models'; import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; @@ -13,6 +14,16 @@ export class CreateNodeLink { export class GetLinkedResources { static readonly type = '[Node Links] Get Linked Resources'; + constructor( + public projectId: string, + public page = 1, + public pageSize = DEFAULT_TABLE_PARAMS.rows + ) {} +} + +export class LoadMoreLinkedResources { + static readonly type = '[Node Links] Load More Linked Resources'; + constructor(public projectId: string) {} } diff --git a/src/app/shared/stores/node-links/node-links.model.ts b/src/app/shared/stores/node-links/node-links.model.ts index 5e892a858..fcfbabc77 100644 --- a/src/app/shared/stores/node-links/node-links.model.ts +++ b/src/app/shared/stores/node-links/node-links.model.ts @@ -1,8 +1,17 @@ +import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params.constants'; import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; import { AsyncStateModel } from '@osf/shared/models/store/async-state.model'; +export interface LinkedResourcesState extends AsyncStateModel { + page: number; + pageSize: number; + projectsTotalCount: number; + registrationsTotalCount: number; + isLoadingMore: boolean; +} + export interface NodeLinksStateModel { - linkedResources: AsyncStateModel; + linkedResources: LinkedResourcesState; } export const NODE_LINKS_DEFAULTS: NodeLinksStateModel = { @@ -11,5 +20,10 @@ export const NODE_LINKS_DEFAULTS: NodeLinksStateModel = { isLoading: false, isSubmitting: false, error: null, + page: 1, + pageSize: DEFAULT_TABLE_PARAMS.rows, + projectsTotalCount: 0, + registrationsTotalCount: 0, + isLoadingMore: false, }, }; diff --git a/src/app/shared/stores/node-links/node-links.selectors.ts b/src/app/shared/stores/node-links/node-links.selectors.ts index 1a6bdfd3d..34c05a28d 100644 --- a/src/app/shared/stores/node-links/node-links.selectors.ts +++ b/src/app/shared/stores/node-links/node-links.selectors.ts @@ -23,4 +23,17 @@ export class NodeLinksSelectors { static getLinkedResourcesSubmitting(state: NodeLinksStateModel) { return state.linkedResources.isSubmitting; } + + @Selector([NodeLinksState]) + static hasMoreLinkedResources(state: NodeLinksStateModel) { + const { page, pageSize, projectsTotalCount, registrationsTotalCount } = state.linkedResources; + const hasMoreProjects = projectsTotalCount > page * pageSize; + const hasMoreRegistrations = registrationsTotalCount > page * pageSize; + return hasMoreProjects || hasMoreRegistrations; + } + + @Selector([NodeLinksState]) + static isLoadingMoreLinkedResources(state: NodeLinksStateModel) { + return state.linkedResources.isLoadingMore; + } } diff --git a/src/app/shared/stores/node-links/node-links.state.ts b/src/app/shared/stores/node-links/node-links.state.ts index 213a2cf4a..5cb8dee86 100644 --- a/src/app/shared/stores/node-links/node-links.state.ts +++ b/src/app/shared/stores/node-links/node-links.state.ts @@ -1,12 +1,21 @@ import { Action, State, StateContext } from '@ngxs/store'; -import { catchError, forkJoin, tap, throwError } from 'rxjs'; +import { catchError, forkJoin, of, tap } from 'rxjs'; import { inject, Injectable } from '@angular/core'; +import { handleSectionError } from '@osf/shared/helpers/state-error.handler'; +import { NodeModel } from '@osf/shared/models/nodes/base-node.model'; +import { PaginatedData } from '@osf/shared/models/paginated-data.model'; import { NodeLinksService } from '@osf/shared/services/node-links.service'; -import { ClearNodeLinks, CreateNodeLink, DeleteNodeLink, GetLinkedResources } from './node-links.actions'; +import { + ClearNodeLinks, + CreateNodeLink, + DeleteNodeLink, + GetLinkedResources, + LoadMoreLinkedResources, +} from './node-links.actions'; import { NODE_LINKS_DEFAULTS, NodeLinksStateModel } from './node-links.model'; @State({ @@ -17,50 +26,104 @@ import { NODE_LINKS_DEFAULTS, NodeLinksStateModel } from './node-links.model'; export class NodeLinksState { nodeLinksService = inject(NodeLinksService); - @Action(CreateNodeLink) - createNodeLink(ctx: StateContext, action: CreateNodeLink) { - const state = ctx.getState(); - ctx.patchState({ - linkedResources: { - ...state.linkedResources, - isSubmitting: true, - }, - }); - - return this.nodeLinksService.createNodeLink(action.currentProjectId, action.resource).pipe( - tap(() => { - ctx.dispatch(new GetLinkedResources(action.currentProjectId)); - }), - catchError((error) => this.handleError(ctx, 'linkedResources', error)) - ); - } - @Action(GetLinkedResources) getLinkedResources(ctx: StateContext, action: GetLinkedResources) { const state = ctx.getState(); + + const page = action.page ?? state.linkedResources.page; + const pageSize = action.pageSize ?? state.linkedResources.pageSize; + ctx.patchState({ linkedResources: { ...state.linkedResources, - isLoading: true, + data: page === 1 ? [] : state.linkedResources.data, + isLoading: page === 1, + isLoadingMore: page > 1, + error: null, }, }); + const emptyPaginatedData: PaginatedData = { + data: [], + totalCount: 0, + pageSize: pageSize, + }; + + const shouldFetchProjects = + page === 1 || + state.linkedResources.projectsTotalCount === 0 || + page <= Math.ceil(state.linkedResources.projectsTotalCount / pageSize); + + const shouldFetchRegistrations = + page === 1 || + state.linkedResources.registrationsTotalCount === 0 || + page <= Math.ceil(state.linkedResources.registrationsTotalCount / pageSize); + + const projectsObservable = shouldFetchProjects + ? this.nodeLinksService + .fetchLinkedProjects(action.projectId, page, pageSize) + .pipe(catchError(() => of(emptyPaginatedData))) + : of(emptyPaginatedData); + + const registrationsObservable = shouldFetchRegistrations + ? this.nodeLinksService + .fetchLinkedRegistrations(action.projectId, page, pageSize) + .pipe(catchError(() => of(emptyPaginatedData))) + : of(emptyPaginatedData); + return forkJoin({ - linkedProjects: this.nodeLinksService.fetchLinkedProjects(action.projectId), - linkedRegistrations: this.nodeLinksService.fetchLinkedRegistrations(action.projectId), + linkedProjects: projectsObservable, + linkedRegistrations: registrationsObservable, }).pipe( tap(({ linkedProjects, linkedRegistrations }) => { - const combinedResources = [...linkedProjects, ...linkedRegistrations]; + const data = + page === 1 + ? [...linkedProjects.data, ...linkedRegistrations.data] + : [...state.linkedResources.data, ...linkedProjects.data, ...linkedRegistrations.data]; + ctx.patchState({ linkedResources: { - data: combinedResources, + ...state.linkedResources, + data, isLoading: false, + isLoadingMore: false, isSubmitting: false, error: null, + page: page, + pageSize: pageSize, + projectsTotalCount: linkedProjects.totalCount, + registrationsTotalCount: linkedRegistrations.totalCount, }, }); }), - catchError((error) => this.handleError(ctx, 'linkedResources', error)) + catchError((error) => handleSectionError(ctx, 'linkedResources', error)) + ); + } + + @Action(LoadMoreLinkedResources) + loadMoreLinkedResources(ctx: StateContext, action: LoadMoreLinkedResources) { + const state = ctx.getState(); + const nextPage = state.linkedResources.page + 1; + const nextPageSize = state.linkedResources.pageSize; + + return ctx.dispatch(new GetLinkedResources(action.projectId, nextPage, nextPageSize)); + } + + @Action(CreateNodeLink) + createNodeLink(ctx: StateContext, action: CreateNodeLink) { + const state = ctx.getState(); + ctx.patchState({ + linkedResources: { + ...state.linkedResources, + isSubmitting: true, + }, + }); + + return this.nodeLinksService.createNodeLink(action.currentProjectId, action.resource).pipe( + tap(() => { + ctx.patchState({ linkedResources: { ...ctx.getState().linkedResources, isSubmitting: false } }); + }), + catchError((error) => handleSectionError(ctx, 'linkedResources', error)) ); } @@ -72,23 +135,13 @@ export class NodeLinksState { linkedResources: { ...state.linkedResources, isSubmitting: true, + error: null, }, }); return this.nodeLinksService.deleteNodeLink(action.projectId, action.linkedResource).pipe( - tap(() => { - const updatedResources = state.linkedResources.data.filter( - (resource) => resource.id !== action.linkedResource.id - ); - ctx.patchState({ - linkedResources: { - data: updatedResources, - isLoading: false, - isSubmitting: false, - error: null, - }, - }); - }) + tap(() => ctx.dispatch(new GetLinkedResources(action.projectId, 1, state.linkedResources.pageSize))), + catchError((error) => handleSectionError(ctx, 'linkedResources', error)) ); } @@ -96,16 +149,4 @@ export class NodeLinksState { clearNodeLinks(ctx: StateContext) { ctx.patchState(NODE_LINKS_DEFAULTS); } - - private handleError(ctx: StateContext, section: keyof NodeLinksStateModel, error: Error) { - ctx.patchState({ - [section]: { - ...ctx.getState()[section], - isLoading: false, - isSubmitting: false, - error: error.message, - }, - }); - return throwError(() => error); - } } From 7cf69d38be96b54a208e68bf2a3ae79559e80c8f Mon Sep 17 00:00:00 2001 From: nsemets Date: Thu, 13 Nov 2025 14:42:49 +0200 Subject: [PATCH 3/5] fix(link-resource-dialog): updated link resource dialog --- .../link-resource-dialog.component.html | 2 +- .../link-resource-dialog.component.ts | 49 +++++++------------ .../linked-resources.component.ts | 27 +++++++--- 3 files changed, 37 insertions(+), 41 deletions(-) diff --git a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.html b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.html index 3360871fa..deb509690 100644 --- a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.html +++ b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.html @@ -95,7 +95,7 @@ } @else { - + } diff --git a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts index e3f9cc320..4a488db25 100644 --- a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts +++ b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts @@ -30,7 +30,7 @@ import { DEFAULT_TABLE_PARAMS } from '@osf/shared/constants/default-table-params import { ResourceSearchMode } from '@osf/shared/enums/resource-search-mode.enum'; import { ResourceType } from '@osf/shared/enums/resource-type.enum'; import { GetMyProjects, GetMyRegistrations, MyResourcesSelectors } from '@osf/shared/stores/my-resources'; -import { CreateNodeLink, DeleteNodeLink, GetLinkedResources, NodeLinksSelectors } from '@osf/shared/stores/node-links'; +import { CreateNodeLink, DeleteNodeLink, NodeLinksSelectors } from '@osf/shared/stores/node-links'; import { MyResourcesItem } from '@shared/models/my-resources/my-resources.models'; import { MyResourcesSearchFilters } from '@shared/models/my-resources/my-resources-search-filters.models'; import { TableParameters } from '@shared/models/table-parameters.model'; @@ -69,7 +69,6 @@ export class LinkResourceDialogComponent { skeletonData: MyResourcesItem[] = Array.from({ length: this.tableRows }, () => ({}) as MyResourcesItem); - currentProject = select(ProjectOverviewSelectors.getProject); myProjects = select(MyResourcesSelectors.getProjects); isMyProjectsLoading = select(MyResourcesSelectors.getProjectsLoading); myRegistrations = select(MyResourcesSelectors.getRegistrations); @@ -78,10 +77,15 @@ export class LinkResourceDialogComponent { totalRegistrationsCount = select(MyResourcesSelectors.getTotalRegistrations); isNodeLinksSubmitting = select(NodeLinksSelectors.getNodeLinksSubmitting); linkedResources = select(NodeLinksSelectors.getLinkedResources); + currentProject = select(ProjectOverviewSelectors.getProject); - currentTableItems = computed(() => - this.resourceType() === ResourceType.Project ? this.myProjects() : this.myRegistrations() - ); + currentResourceId = computed(() => this.currentProject()?.id); + + currentTableItems = computed(() => { + const items = this.resourceType() === ResourceType.Project ? this.myProjects() : this.myRegistrations(); + const currentId = this.currentResourceId(); + return currentId ? items.filter((item) => item.id !== currentId) : items; + }); isCurrentTableLoading = computed(() => this.resourceType() === ResourceType.Project ? this.isMyProjectsLoading() : this.isMyRegistrationsLoading() @@ -109,13 +113,11 @@ export class LinkResourceDialogComponent { getRegistrations: GetMyRegistrations, createNodeLink: CreateNodeLink, deleteNodeLink: DeleteNodeLink, - getLinkedProjects: GetLinkedResources, }); constructor() { this.setupSearchEffect(); this.setupSearchSubscription(); - this.setupNodeLinksEffect(); } onSearchModeChange(mode: ResourceSearchMode): void { @@ -149,15 +151,6 @@ export class LinkResourceDialogComponent { }); } - setupNodeLinksEffect() { - effect(() => { - const currentProject = this.currentProject(); - if (currentProject) { - this.actions.getLinkedProjects(currentProject.id); - } - }); - } - setupSearchSubscription(): void { this.searchControl.valueChanges .pipe(debounceTime(300), distinctUntilChanged(), takeUntilDestroyed(this.destroyRef)) @@ -168,21 +161,13 @@ export class LinkResourceDialogComponent { } handleCloseDialog() { - const currentProjectId = this.currentProject()?.id; - - if (!currentProjectId) { - this.dialogRef.close(); - return; - } - - this.actions.getLinkedProjects(currentProjectId); this.dialogRef.close(); } handleToggleNodeLink(resource: MyResourcesItem) { - const currentProjectId = this.currentProject()?.id; + const currentResourceId = this.currentResourceId(); - if (!currentProjectId) { + if (!currentResourceId) { return; } @@ -194,9 +179,9 @@ export class LinkResourceDialogComponent { if (!linkToDelete) return; - this.actions.deleteNodeLink(currentProjectId, linkToDelete); + this.actions.deleteNodeLink(currentResourceId, linkToDelete); } else { - this.actions.createNodeLink(currentProjectId, resource); + this.actions.createNodeLink(currentResourceId, resource); } } @@ -207,13 +192,13 @@ export class LinkResourceDialogComponent { }; untracked(() => { - const currentProjectId = this.currentProject()?.id; - if (!currentProjectId) return; + const currentResourceId = this.currentResourceId(); + if (!currentResourceId) return; if (resourceType === ResourceType.Project) { - this.actions.getProjects(this.currentPage(), this.tableRows, searchFilters, searchMode, currentProjectId); + this.actions.getProjects(this.currentPage(), this.tableRows, searchFilters, searchMode, undefined); } else if (resourceType === ResourceType.Registration) { - this.actions.getRegistrations(this.currentPage(), this.tableRows, searchFilters, searchMode, currentProjectId); + this.actions.getRegistrations(this.currentPage(), this.tableRows, searchFilters, searchMode, undefined); } }); } diff --git a/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts b/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts index 4963df808..695bea606 100644 --- a/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts +++ b/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts @@ -5,13 +5,14 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Skeleton } from 'primeng/skeleton'; -import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject, input } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ContributorsListComponent } from '@osf/shared/components/contributors-list/contributors-list.component'; import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { TruncatedTextComponent } from '@osf/shared/components/truncated-text/truncated-text.component'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; -import { LoadMoreLinkedResources, NodeLinksSelectors } from '@osf/shared/stores/node-links'; +import { GetLinkedResources, LoadMoreLinkedResources, NodeLinksSelectors } from '@osf/shared/stores/node-links'; import { ProjectOverviewSelectors } from '../../store'; import { DeleteNodeLinkDialogComponent } from '../delete-node-link-dialog/delete-node-link-dialog.component'; @@ -26,6 +27,7 @@ import { LinkResourceDialogComponent } from '../link-resource-dialog/link-resour }) export class LinkedResourcesComponent { private customDialogService = inject(CustomDialogService); + private readonly destroyRef = inject(DestroyRef); canEdit = input.required(); @@ -35,14 +37,23 @@ export class LinkedResourcesComponent { isLoadingMoreLinkedResources = select(NodeLinksSelectors.isLoadingMoreLinkedResources); currentProject = select(ProjectOverviewSelectors.getProject); - private readonly actions = createDispatchMap({ loadMoreLinkedResources: LoadMoreLinkedResources }); + private readonly actions = createDispatchMap({ + getLinkedResources: GetLinkedResources, + loadMoreLinkedResources: LoadMoreLinkedResources, + }); openLinkProjectModal() { - this.customDialogService.open(LinkResourceDialogComponent, { - header: 'project.overview.dialog.linkProject.header', - width: '850px', - closable: false, - }); + const project = this.currentProject(); + if (!project) return; + + this.customDialogService + .open(LinkResourceDialogComponent, { + header: 'project.overview.dialog.linkProject.header', + width: '850px', + closable: false, + }) + .onClose.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => this.actions.getLinkedResources(project.id)); } openDeleteResourceModal(resourceId: string): void { From 49c08b0dba8d6852d912d29842f20b2ae44b124f Mon Sep 17 00:00:00 2001 From: nsemets Date: Thu, 13 Nov 2025 15:21:22 +0200 Subject: [PATCH 4/5] fix(linked-resources): added logic to track changes --- .../delete-node-link-dialog.component.ts | 7 +++-- .../link-resource-dialog.component.ts | 3 ++- .../linked-resources.component.ts | 27 ++++++++++++++----- .../stores/node-links/node-links.model.ts | 2 ++ .../stores/node-links/node-links.selectors.ts | 5 ++++ .../stores/node-links/node-links.state.ts | 12 +++++++-- 6 files changed, 42 insertions(+), 14 deletions(-) diff --git a/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.ts b/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.ts index 5fd2ff58e..66f321915 100644 --- a/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.ts +++ b/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.ts @@ -8,7 +8,7 @@ import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core'; import { ToastService } from '@osf/shared/services/toast.service'; -import { DeleteNodeLink, GetLinkedResources, NodeLinksSelectors } from '@osf/shared/stores/node-links'; +import { DeleteNodeLink, NodeLinksSelectors } from '@osf/shared/stores/node-links'; import { ProjectOverviewSelectors } from '../../store'; @@ -28,7 +28,7 @@ export class DeleteNodeLinkDialogComponent { currentProject = select(ProjectOverviewSelectors.getProject); isSubmitting = select(NodeLinksSelectors.getNodeLinksSubmitting); - actions = createDispatchMap({ deleteNodeLink: DeleteNodeLink, getLinkedResources: GetLinkedResources }); + actions = createDispatchMap({ deleteNodeLink: DeleteNodeLink }); handleDeleteNodeLink(): void { const project = this.currentProject(); @@ -38,9 +38,8 @@ export class DeleteNodeLinkDialogComponent { this.actions.deleteNodeLink(project.id, currentLink).subscribe({ next: () => { - this.dialogRef.close(); - this.actions.getLinkedResources(project.id); this.toastService.showSuccess('project.overview.dialog.toast.deleteNodeLink.success'); + this.dialogRef.close({ hasChanges: true }); }, }); } diff --git a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts index 4a488db25..fe5542a89 100644 --- a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts +++ b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.ts @@ -77,6 +77,7 @@ export class LinkResourceDialogComponent { totalRegistrationsCount = select(MyResourcesSelectors.getTotalRegistrations); isNodeLinksSubmitting = select(NodeLinksSelectors.getNodeLinksSubmitting); linkedResources = select(NodeLinksSelectors.getLinkedResources); + hasChanges = select(NodeLinksSelectors.getNodeLinksHasChanges); currentProject = select(ProjectOverviewSelectors.getProject); currentResourceId = computed(() => this.currentProject()?.id); @@ -161,7 +162,7 @@ export class LinkResourceDialogComponent { } handleCloseDialog() { - this.dialogRef.close(); + this.dialogRef.close({ hasChanges: this.hasChanges() }); } handleToggleNodeLink(resource: MyResourcesItem) { diff --git a/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts b/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts index 695bea606..cd1d88944 100644 --- a/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts +++ b/src/app/features/project/overview/components/linked-resources/linked-resources.component.ts @@ -5,6 +5,8 @@ import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Skeleton } from 'primeng/skeleton'; +import { filter } from 'rxjs'; + import { ChangeDetectionStrategy, Component, DestroyRef, inject, input } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -44,6 +46,7 @@ export class LinkedResourcesComponent { openLinkProjectModal() { const project = this.currentProject(); + if (!project) return; this.customDialogService @@ -52,20 +55,30 @@ export class LinkedResourcesComponent { width: '850px', closable: false, }) - .onClose.pipe(takeUntilDestroyed(this.destroyRef)) + .onClose.pipe( + filter((data) => !!data?.hasChanges), + takeUntilDestroyed(this.destroyRef) + ) .subscribe(() => this.actions.getLinkedResources(project.id)); } openDeleteResourceModal(resourceId: string): void { const currentLink = this.linkedResources().find((resource) => resource.id === resourceId); + const project = this.currentProject(); - if (!currentLink) return; + if (!currentLink || !project) return; - this.customDialogService.open(DeleteNodeLinkDialogComponent, { - header: 'project.overview.dialog.deleteNodeLink.header', - width: '650px', - data: { currentLink }, - }); + this.customDialogService + .open(DeleteNodeLinkDialogComponent, { + header: 'project.overview.dialog.deleteNodeLink.header', + width: '650px', + data: { currentLink }, + }) + .onClose.pipe( + filter((data) => !!data?.hasChanges), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => this.actions.getLinkedResources(project.id)); } loadMore(): void { diff --git a/src/app/shared/stores/node-links/node-links.model.ts b/src/app/shared/stores/node-links/node-links.model.ts index fcfbabc77..1f65b0780 100644 --- a/src/app/shared/stores/node-links/node-links.model.ts +++ b/src/app/shared/stores/node-links/node-links.model.ts @@ -8,6 +8,7 @@ export interface LinkedResourcesState extends AsyncStateModel { projectsTotalCount: number; registrationsTotalCount: number; isLoadingMore: boolean; + hasChanges: boolean; } export interface NodeLinksStateModel { @@ -25,5 +26,6 @@ export const NODE_LINKS_DEFAULTS: NodeLinksStateModel = { projectsTotalCount: 0, registrationsTotalCount: 0, isLoadingMore: false, + hasChanges: false, }, }; diff --git a/src/app/shared/stores/node-links/node-links.selectors.ts b/src/app/shared/stores/node-links/node-links.selectors.ts index 34c05a28d..e2e814db3 100644 --- a/src/app/shared/stores/node-links/node-links.selectors.ts +++ b/src/app/shared/stores/node-links/node-links.selectors.ts @@ -36,4 +36,9 @@ export class NodeLinksSelectors { static isLoadingMoreLinkedResources(state: NodeLinksStateModel) { return state.linkedResources.isLoadingMore; } + + @Selector([NodeLinksState]) + static getNodeLinksHasChanges(state: NodeLinksStateModel) { + return state.linkedResources.hasChanges; + } } diff --git a/src/app/shared/stores/node-links/node-links.state.ts b/src/app/shared/stores/node-links/node-links.state.ts index 5cb8dee86..bbdbde0ea 100644 --- a/src/app/shared/stores/node-links/node-links.state.ts +++ b/src/app/shared/stores/node-links/node-links.state.ts @@ -40,6 +40,7 @@ export class NodeLinksState { isLoading: page === 1, isLoadingMore: page > 1, error: null, + hasChanges: false, }, }); @@ -93,6 +94,7 @@ export class NodeLinksState { pageSize: pageSize, projectsTotalCount: linkedProjects.totalCount, registrationsTotalCount: linkedRegistrations.totalCount, + hasChanges: false, }, }); }), @@ -121,7 +123,9 @@ export class NodeLinksState { return this.nodeLinksService.createNodeLink(action.currentProjectId, action.resource).pipe( tap(() => { - ctx.patchState({ linkedResources: { ...ctx.getState().linkedResources, isSubmitting: false } }); + ctx.patchState({ + linkedResources: { ...ctx.getState().linkedResources, isSubmitting: false, hasChanges: true }, + }); }), catchError((error) => handleSectionError(ctx, 'linkedResources', error)) ); @@ -140,7 +144,11 @@ export class NodeLinksState { }); return this.nodeLinksService.deleteNodeLink(action.projectId, action.linkedResource).pipe( - tap(() => ctx.dispatch(new GetLinkedResources(action.projectId, 1, state.linkedResources.pageSize))), + tap(() => { + ctx.patchState({ + linkedResources: { ...ctx.getState().linkedResources, isSubmitting: false, hasChanges: true }, + }); + }), catchError((error) => handleSectionError(ctx, 'linkedResources', error)) ); } From 0670c9524782fd3e4de99e45cd7eded1f68b874d Mon Sep 17 00:00:00 2001 From: nsemets Date: Thu, 13 Nov 2025 15:31:30 +0200 Subject: [PATCH 5/5] fix(linked-resources): updated unit tests --- .../delete-node-link-dialog.component.spec.ts | 92 +++++++++- .../link-resource-dialog.component.spec.ts | 167 +++++++++++++++++- .../linked-resources.component.spec.ts | 5 + src/testing/mocks/my-resources.mock.ts | 39 ++++ 4 files changed, 294 insertions(+), 9 deletions(-) create mode 100644 src/testing/mocks/my-resources.mock.ts diff --git a/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.spec.ts b/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.spec.ts index bd54dd026..b546c4461 100644 --- a/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.spec.ts +++ b/src/app/features/project/overview/components/delete-node-link-dialog/delete-node-link-dialog.component.spec.ts @@ -1,22 +1,104 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Store } from '@ngxs/store'; + +import { MockProvider } from 'ng-mocks'; + +import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; + +import { of } from 'rxjs'; + +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import { ToastService } from '@osf/shared/services/toast.service'; +import { DeleteNodeLink, NodeLinksSelectors } from '@osf/shared/stores/node-links'; + +import { ProjectOverviewSelectors } from '../../store'; import { DeleteNodeLinkDialogComponent } from './delete-node-link-dialog.component'; -describe.skip('DeleteNodeLinkDialogComponent', () => { +import { DynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; +import { MOCK_NODE_WITH_ADMIN } from '@testing/mocks/node.mock'; +import { ToastServiceMock } from '@testing/mocks/toast.service.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('DeleteNodeLinkDialogComponent', () => { let component: DeleteNodeLinkDialogComponent; let fixture: ComponentFixture; + let store: jest.Mocked; + let dialogRef: jest.Mocked; + let dialogConfig: jest.Mocked; + let toastService: jest.Mocked; + + const mockProject = { ...MOCK_NODE_WITH_ADMIN, id: 'test-project-id' }; + const mockCurrentLink = { ...MOCK_NODE_WITH_ADMIN, id: 'linked-resource-id', title: 'Linked Resource' }; beforeEach(async () => { + dialogConfig = { + data: { currentLink: mockCurrentLink }, + } as jest.Mocked; + await TestBed.configureTestingModule({ - imports: [DeleteNodeLinkDialogComponent], + imports: [DeleteNodeLinkDialogComponent, OSFTestingModule], + providers: [ + DynamicDialogRefMock, + ToastServiceMock, + MockProvider(DynamicDialogConfig, dialogConfig), + provideMockStore({ + signals: [ + { selector: ProjectOverviewSelectors.getProject, value: mockProject }, + { selector: NodeLinksSelectors.getNodeLinksSubmitting, value: false }, + ], + }), + ], }).compileComponents(); + store = TestBed.inject(Store) as jest.Mocked; + store.dispatch = jest.fn().mockReturnValue(of(true)); fixture = TestBed.createComponent(DeleteNodeLinkDialogComponent); component = fixture.componentInstance; + dialogRef = TestBed.inject(DynamicDialogRef) as jest.Mocked; + toastService = TestBed.inject(ToastService) as jest.Mocked; fixture.detectChanges(); }); - it('should create', () => { - expect(component).toBeTruthy(); + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should initialize currentProject selector', () => { + expect(component.currentProject()).toEqual(mockProject); }); + + it('should initialize isSubmitting selector', () => { + expect(component.isSubmitting()).toBe(false); + }); + + it('should initialize actions with deleteNodeLink mapping', () => { + expect(component.actions.deleteNodeLink).toBeDefined(); + }); + + it('should dispatch DeleteNodeLink action with correct parameters on successful deletion', () => { + component.handleDeleteNodeLink(); + + expect(store.dispatch).toHaveBeenCalledWith(expect.any(DeleteNodeLink)); + const call = (store.dispatch as jest.Mock).mock.calls.find((call) => call[0] instanceof DeleteNodeLink); + expect(call).toBeDefined(); + const action = call[0] as DeleteNodeLink; + expect(action.projectId).toBe('test-project-id'); + expect(action.linkedResource).toEqual(mockCurrentLink); + }); + + it('should show success toast on successful deletion', fakeAsync(() => { + component.handleDeleteNodeLink(); + tick(); + + expect(toastService.showSuccess).toHaveBeenCalledWith('project.overview.dialog.toast.deleteNodeLink.success'); + })); + + it('should close dialog with hasChanges true on successful deletion', fakeAsync(() => { + component.handleDeleteNodeLink(); + tick(); + + expect(dialogRef.close).toHaveBeenCalledWith({ hasChanges: true }); + })); }); diff --git a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.spec.ts b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.spec.ts index 14f9d04ba..f94d9f4c9 100644 --- a/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.spec.ts +++ b/src/app/features/project/overview/components/link-resource-dialog/link-resource-dialog.component.spec.ts @@ -1,26 +1,185 @@ +import { Store } from '@ngxs/store'; + import { MockComponents } from 'ng-mocks'; +import { TablePageEvent } from 'primeng/table'; + import { ComponentFixture, TestBed } from '@angular/core/testing'; import { SearchInputComponent } from '@osf/shared/components/search-input/search-input.component'; +import { ResourceSearchMode } from '@osf/shared/enums/resource-search-mode.enum'; +import { ResourceType } from '@osf/shared/enums/resource-type.enum'; +import { MyResourcesSelectors } from '@osf/shared/stores/my-resources'; +import { NodeLinksSelectors } from '@osf/shared/stores/node-links'; +import { MyResourcesItem } from '@shared/models/my-resources/my-resources.models'; + +import { ProjectOverviewSelectors } from '../../store'; import { LinkResourceDialogComponent } from './link-resource-dialog.component'; -describe.skip('LinkProjectDialogComponent', () => { +import { DynamicDialogRefMock } from '@testing/mocks/dynamic-dialog-ref.mock'; +import { + MOCK_MY_RESOURCES_ITEM_PROJECT, + MOCK_MY_RESOURCES_ITEM_PROJECT_PRIVATE, + MOCK_MY_RESOURCES_ITEM_REGISTRATION, +} from '@testing/mocks/my-resources.mock'; +import { MOCK_NODE_WITH_ADMIN } from '@testing/mocks/node.mock'; +import { OSFTestingModule } from '@testing/osf.testing.module'; +import { provideMockStore } from '@testing/providers/store-provider.mock'; + +describe('LinkResourceDialogComponent', () => { let component: LinkResourceDialogComponent; let fixture: ComponentFixture; + let store: Store; + let dialogRef: { close: jest.Mock }; + + const mockProjects: MyResourcesItem[] = [MOCK_MY_RESOURCES_ITEM_PROJECT, MOCK_MY_RESOURCES_ITEM_PROJECT_PRIVATE]; + + const mockRegistrations: MyResourcesItem[] = [MOCK_MY_RESOURCES_ITEM_REGISTRATION]; + + const mockLinkedResources = [{ ...MOCK_NODE_WITH_ADMIN, id: 'project-1', title: 'Linked Project 1' }]; + + const mockCurrentProject = { ...MOCK_NODE_WITH_ADMIN, id: 'current-project-id' }; beforeEach(async () => { + dialogRef = { close: jest.fn() }; + await TestBed.configureTestingModule({ - imports: [LinkResourceDialogComponent, ...MockComponents(SearchInputComponent)], + imports: [LinkResourceDialogComponent, OSFTestingModule, ...MockComponents(SearchInputComponent)], + providers: [ + provideMockStore({ + signals: [ + { selector: MyResourcesSelectors.getProjects, value: mockProjects }, + { selector: MyResourcesSelectors.getProjectsLoading, value: false }, + { selector: MyResourcesSelectors.getRegistrations, value: mockRegistrations }, + { selector: MyResourcesSelectors.getRegistrationsLoading, value: false }, + { selector: MyResourcesSelectors.getTotalProjects, value: 2 }, + { selector: MyResourcesSelectors.getTotalRegistrations, value: 1 }, + { selector: NodeLinksSelectors.getNodeLinksSubmitting, value: false }, + { selector: NodeLinksSelectors.getLinkedResources, value: mockLinkedResources }, + { selector: ProjectOverviewSelectors.getProject, value: mockCurrentProject }, + ], + }), + { provide: DynamicDialogRefMock.provide, useValue: dialogRef }, + ], }).compileComponents(); fixture = TestBed.createComponent(LinkResourceDialogComponent); component = fixture.componentInstance; + store = TestBed.inject(Store); + fixture.detectChanges(); + }); + + it('should initialize with default values', () => { + expect(component.currentPage()).toBe(1); + expect(component.searchMode()).toBe(ResourceSearchMode.User); + expect(component.resourceType()).toBe(ResourceType.Project); + expect(component.searchControl.value).toBe(''); + }); + + it('should initialize skeleton data with correct length', () => { + expect(component.skeletonData.length).toBe(component.tableRows); + }); + + it('should compute currentResourceId from currentProject', () => { + expect(component.currentResourceId()).toBe('current-project-id'); + }); + + it('should compute isCurrentTableLoading for registrations', () => { + component.resourceType.set(ResourceType.Registration); fixture.detectChanges(); + expect(component.isCurrentTableLoading()).toBe(false); }); - it('should create', () => { - expect(component).toBeTruthy(); + it('should compute currentTotalCount for registrations', () => { + component.resourceType.set(ResourceType.Registration); + fixture.detectChanges(); + expect(component.currentTotalCount()).toBe(1); + }); + + it('should compute tableParams correctly', () => { + const params = component.tableParams(); + expect(params.rows).toBe(component.tableRows); + expect(params.firstRowIndex).toBe(0); + expect(params.paginator).toBe(false); + expect(params.totalRecords).toBe(2); + }); + + it('should compute linkedResourceIds as a Set', () => { + const linkedIds = component.linkedResourceIds(); + expect(linkedIds).toBeInstanceOf(Set); + expect(linkedIds.has('project-1')).toBe(true); + expect(linkedIds.has('project-2')).toBe(false); + }); + + it('should update searchMode and reset to first page', () => { + component.currentPage.set(2); + component.onSearchModeChange(ResourceSearchMode.All); + + expect(component.searchMode()).toBe(ResourceSearchMode.All); + expect(component.currentPage()).toBe(1); + }); + + it('should update resourceType and reset to first page', () => { + component.currentPage.set(2); + component.onObjectTypeChange(ResourceType.Registration); + + expect(component.resourceType()).toBe(ResourceType.Registration); + expect(component.currentPage()).toBe(1); + }); + + it('should update currentPage and trigger search', () => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + const event: TablePageEvent = { + first: 6, + rows: 6, + }; + + component.onPageChange(event); + + expect(component.currentPage()).toBe(2); + expect(dispatchSpy).toHaveBeenCalled(); + }); + + it('should return true for linked items', () => { + expect(component.isItemLinked('project-1')).toBe(true); + }); + + it('should return false for non-linked items', () => { + expect(component.isItemLinked('project-2')).toBe(false); + }); + + it('should close the dialog', () => { + component.handleCloseDialog(); + expect(dialogRef.close).toHaveBeenCalled(); + }); + + it('should trigger search when searchMode changes', () => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + component.onSearchModeChange(ResourceSearchMode.All); + + expect(dispatchSpy).toHaveBeenCalled(); + }); + + it('should trigger search when resourceType changes', () => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + component.onObjectTypeChange(ResourceType.Registration); + + expect(dispatchSpy).toHaveBeenCalled(); + }); + + it('should handle page change with zero first value', () => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + const event: TablePageEvent = { + first: 0, + rows: 6, + }; + + component.onPageChange(event); + + expect(component.currentPage()).toBe(1); + expect(dispatchSpy).toHaveBeenCalled(); }); }); diff --git a/src/app/features/project/overview/components/linked-resources/linked-resources.component.spec.ts b/src/app/features/project/overview/components/linked-resources/linked-resources.component.spec.ts index 42f53a8f4..14e948bab 100644 --- a/src/app/features/project/overview/components/linked-resources/linked-resources.component.spec.ts +++ b/src/app/features/project/overview/components/linked-resources/linked-resources.component.spec.ts @@ -7,6 +7,7 @@ import { IconComponent } from '@osf/shared/components/icon/icon.component'; import { CustomDialogService } from '@osf/shared/services/custom-dialog.service'; import { NodeLinksSelectors } from '@osf/shared/stores/node-links'; +import { ProjectOverviewSelectors } from '../../store'; import { DeleteNodeLinkDialogComponent } from '../delete-node-link-dialog/delete-node-link-dialog.component'; import { LinkResourceDialogComponent } from '../link-resource-dialog/link-resource-dialog.component'; @@ -42,6 +43,9 @@ describe('LinkedProjectsComponent', () => { signals: [ { selector: NodeLinksSelectors.getLinkedResources, value: mockLinkedResources }, { selector: NodeLinksSelectors.getLinkedResourcesLoading, value: false }, + { selector: NodeLinksSelectors.hasMoreLinkedResources, value: false }, + { selector: NodeLinksSelectors.isLoadingMoreLinkedResources, value: false }, + { selector: ProjectOverviewSelectors.getProject, value: MOCK_NODE_WITH_ADMIN }, ], }), MockProvider(CustomDialogService, customDialogServiceMock), @@ -60,6 +64,7 @@ describe('LinkedProjectsComponent', () => { expect(customDialogServiceMock.open).toHaveBeenCalledWith(LinkResourceDialogComponent, { header: 'project.overview.dialog.linkProject.header', width: '850px', + closable: false, }); }); diff --git a/src/testing/mocks/my-resources.mock.ts b/src/testing/mocks/my-resources.mock.ts new file mode 100644 index 000000000..fff22e697 --- /dev/null +++ b/src/testing/mocks/my-resources.mock.ts @@ -0,0 +1,39 @@ +import { MyResourcesItem } from '@shared/models/my-resources/my-resources.models'; + +import { MOCK_CONTRIBUTOR } from './contributors.mock'; + +export const MOCK_MY_RESOURCES_ITEM_PROJECT: MyResourcesItem = { + id: 'project-1', + type: 'nodes', + title: 'Test Project', + dateCreated: '2024-01-01T00:00:00.000Z', + dateModified: '2024-01-02T00:00:00.000Z', + isPublic: true, + contributors: [MOCK_CONTRIBUTOR], +}; + +export const MOCK_MY_RESOURCES_ITEM_PROJECT_PRIVATE: MyResourcesItem = { + id: 'project-2', + type: 'nodes', + title: 'Private Test Project', + dateCreated: '2024-01-03T00:00:00.000Z', + dateModified: '2024-01-04T00:00:00.000Z', + isPublic: false, + contributors: [], +}; + +export const MOCK_MY_RESOURCES_ITEM_REGISTRATION: MyResourcesItem = { + id: 'registration-1', + type: 'registrations', + title: 'Test Registration', + dateCreated: '2024-01-05T00:00:00.000Z', + dateModified: '2024-01-06T00:00:00.000Z', + isPublic: true, + contributors: [MOCK_CONTRIBUTOR], +}; + +export const MOCK_MY_RESOURCES_ITEMS: MyResourcesItem[] = [ + MOCK_MY_RESOURCES_ITEM_PROJECT, + MOCK_MY_RESOURCES_ITEM_PROJECT_PRIVATE, + MOCK_MY_RESOURCES_ITEM_REGISTRATION, +];