From 2084436b3a685d4116d2b3271f7ec5b381f3bc20 Mon Sep 17 00:00:00 2001 From: nsemets Date: Thu, 2 Oct 2025 18:21:12 +0300 Subject: [PATCH 1/3] fix(requests): added headers options --- src/app/core/interceptors/auth.interceptor.ts | 11 +++++++---- src/app/shared/services/json-api.service.ts | 13 +++++++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/app/core/interceptors/auth.interceptor.ts b/src/app/core/interceptors/auth.interceptor.ts index 108fbd579..9c73826bc 100644 --- a/src/app/core/interceptors/auth.interceptor.ts +++ b/src/app/core/interceptors/auth.interceptor.ts @@ -14,10 +14,13 @@ export const authInterceptor: HttpInterceptorFn = ( const csrfToken = cookieService.get('api-csrf'); if (!req.url.includes('/api.crossref.org/funders')) { - const headers: Record = { - Accept: req.responseType === 'text' ? '*/*' : 'application/vnd.api+json;version=2.20', - 'Content-Type': 'application/vnd.api+json', - }; + const headers: Record = {}; + + headers['Accept'] = req.responseType === 'text' ? '*/*' : 'application/vnd.api+json;version=2.20'; + + if (!req.headers.has('Content-Type')) { + headers['Content-Type'] = 'application/vnd.api+json'; + } if (csrfToken) { headers['X-CSRFToken'] = csrfToken; diff --git a/src/app/shared/services/json-api.service.ts b/src/app/shared/services/json-api.service.ts index 0c50a9f4d..93ec8dff3 100644 --- a/src/app/shared/services/json-api.service.ts +++ b/src/app/shared/services/json-api.service.ts @@ -43,9 +43,14 @@ export class JsonApiService { }); } - patch(url: string, body: unknown, params?: Record): Observable { + patch( + url: string, + body: unknown, + params?: Record, + headers?: Record + ): Observable { return this.http - .patch>(url, body, { params: this.buildHttpParams(params) }) + .patch>(url, body, { params: this.buildHttpParams(params), headers }) .pipe(map((response) => response.data)); } @@ -68,7 +73,7 @@ export class JsonApiService { }); } - delete(url: string, body?: unknown): Observable { - return this.http.delete(url, { body: body }); + delete(url: string, body?: unknown, headers?: Record): Observable { + return this.http.delete(url, { body, headers }); } } From f6cf1a25c3fa143144dfe8d815f1908bd1fa1246 Mon Sep 17 00:00:00 2001 From: Roman Nastyuk Date: Fri, 3 Oct 2025 13:05:38 +0300 Subject: [PATCH 2/3] feat(ang-734): added ability to delete project with components --- .../delete-project-dialog.component.html | 42 ++++++++++ .../delete-project-dialog.component.scss | 0 .../delete-project-dialog.component.ts | 83 +++++++++++++++++++ .../project/settings/components/index.ts | 1 + .../settings/services/settings.service.ts | 16 +++- .../project/settings/settings.component.ts | 41 +++++---- .../settings/store/settings.actions.ts | 4 +- .../project/settings/store/settings.model.ts | 1 + .../settings/store/settings.selectors.ts | 5 ++ .../project/settings/store/settings.state.ts | 21 ++++- .../shared/mappers/nodes/base-node.mapper.ts | 1 + .../shared/models/current-resource.model.ts | 1 + .../models/guid-response-json-api.model.ts | 3 + .../models/nodes/node-with-children.model.ts | 1 + src/app/shared/services/resource.service.ts | 1 + src/assets/i18n/en.json | 9 +- 16 files changed, 207 insertions(+), 23 deletions(-) create mode 100644 src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.html create mode 100644 src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.scss create mode 100644 src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.ts diff --git a/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.html b/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.html new file mode 100644 index 000000000..c0da73d2b --- /dev/null +++ b/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.html @@ -0,0 +1,42 @@ +@if (hasAdminAccessForAllComponents()) { +

{{ 'project.deleteProject.dialog.listMessage' | translate }}

+ @if (isLoading()) { + + } @else { +
    + @for (project of projects(); track project.id) { +
  • {{ project.title }}
  • + } +
+ } + +

{{ 'project.deleteProject.dialog.warningMessage' | translate }}

+ +

+ {{ 'project.deleteProject.dialog.confirmation' | translate }} + {{ selectedScientist() }} +

+ + +} @else { +

+} +
+ + +
diff --git a/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.scss b/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.ts b/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.ts new file mode 100644 index 000000000..bb94b5d34 --- /dev/null +++ b/src/app/features/project/settings/components/delete-project-dialog/delete-project-dialog.component.ts @@ -0,0 +1,83 @@ +import { createDispatchMap, select } from '@ngxs/store'; + +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { DynamicDialogRef } from 'primeng/dynamicdialog'; +import { InputText } from 'primeng/inputtext'; +import { Skeleton } from 'primeng/skeleton'; + +import { ChangeDetectionStrategy, Component, computed, DestroyRef, inject, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; + +import { DeleteProject, SettingsSelectors } from '@osf/features/project/settings/store'; +import { ScientistsNames } from '@osf/shared/constants'; +import { ToastService } from '@osf/shared/services'; +import { CurrentResourceSelectors } from '@osf/shared/stores'; +import { UserPermissions } from '@shared/enums'; + +@Component({ + selector: 'osf-delete-project-dialog', + imports: [TranslatePipe, Button, InputText, FormsModule, Skeleton], + templateUrl: './delete-project-dialog.component.html', + styleUrl: './delete-project-dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DeleteProjectDialogComponent { + private toastService = inject(ToastService); + private router = inject(Router); + dialogRef = inject(DynamicDialogRef); + destroyRef = inject(DestroyRef); + + scientistNames = ScientistsNames; + userInput = signal(''); + + isLoading = select(CurrentResourceSelectors.isResourceWithChildrenLoading); + isSubmitting = select(SettingsSelectors.isSettingsSubmitting); + projects = select(CurrentResourceSelectors.getResourceWithChildren); + + hasAdminAccessForAllComponents = computed(() => { + const projects = this.projects(); + if (!projects || !projects.length) return false; + + return projects.every((project) => { + return project.permissions?.includes(UserPermissions.Admin); + }); + }); + + selectedScientist = computed(() => { + const names = Object.values(this.scientistNames); + return names[Math.floor(Math.random() * names.length)]; + }); + + actions = createDispatchMap({ + deleteProject: DeleteProject, + }); + + isInputValid(): boolean { + return this.userInput() === this.selectedScientist(); + } + + onInputChange(value: string): void { + this.userInput.set(value); + } + + handleDeleteProject(): void { + const projects = this.projects(); + + if (!projects || !projects.length) return; + + this.actions + .deleteProject(projects) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.dialogRef.close({ success: true }); + this.toastService.showSuccess('project.deleteProject.success'); + this.router.navigate(['/']); + }, + }); + } +} diff --git a/src/app/features/project/settings/components/index.ts b/src/app/features/project/settings/components/index.ts index 48f7a5372..37ac121d2 100644 --- a/src/app/features/project/settings/components/index.ts +++ b/src/app/features/project/settings/components/index.ts @@ -1,3 +1,4 @@ +export { DeleteProjectDialogComponent } from './delete-project-dialog/delete-project-dialog.component'; export { ProjectDetailSettingAccordionComponent } from './project-detail-setting-accordion/project-detail-setting-accordion.component'; export { ProjectSettingNotificationsComponent } from './project-setting-notifications/project-setting-notifications.component'; export { SettingsAccessRequestsCardComponent } from './settings-access-requests-card/settings-access-requests-card.component'; diff --git a/src/app/features/project/settings/services/settings.service.ts b/src/app/features/project/settings/services/settings.service.ts index d7ed3df47..28e8fb5af 100644 --- a/src/app/features/project/settings/services/settings.service.ts +++ b/src/app/features/project/settings/services/settings.service.ts @@ -6,6 +6,7 @@ import { ENVIRONMENT } from '@core/provider/environment.provider'; import { SubscriptionFrequency } from '@osf/shared/enums'; import { NotificationSubscriptionMapper } from '@osf/shared/mappers'; import { + NodeShortInfoModel, NotificationSubscription, NotificationSubscriptionGetResponseJsonApi, ResponseJsonApi, @@ -81,8 +82,19 @@ export class SettingsService { .pipe(map((response) => SettingsMapper.fromNodeResponse(response))); } - deleteProject(projectId: string): Observable { - return this.jsonApiService.delete(`${this.apiUrl}/nodes/${projectId}/`); + deleteProject(projects: NodeShortInfoModel[]): Observable { + const payload = { + data: projects.map((project) => ({ + type: 'nodes', + id: project.id, + })), + }; + + const headers = { + 'Content-Type': 'application/vnd.api+json; ext=bulk', + }; + + return this.jsonApiService.delete(`${this.apiUrl}/nodes/`, payload, headers); } deleteInstitution(institutionId: string, projectId: string): Observable { diff --git a/src/app/features/project/settings/settings.component.ts b/src/app/features/project/settings/settings.component.ts index 288f6f049..42756ce97 100644 --- a/src/app/features/project/settings/settings.component.ts +++ b/src/app/features/project/settings/settings.component.ts @@ -7,16 +7,25 @@ import { map, of } from 'rxjs'; import { ChangeDetectionStrategy, Component, computed, effect, inject, OnInit, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { UserSelectors } from '@core/store/user'; import { LoadingSpinnerComponent, SubHeaderComponent } from '@osf/shared/components'; import { ResourceType, SubscriptionEvent, SubscriptionFrequency, UserPermissions } from '@osf/shared/enums'; +import { IS_MEDIUM } from '@osf/shared/helpers'; import { Institution, UpdateNodeRequestModel, ViewOnlyLinkModel } from '@osf/shared/models'; -import { CustomConfirmationService, LoaderService, ToastService } from '@osf/shared/services'; -import { DeleteViewOnlyLink, FetchViewOnlyLinks, GetResource, ViewOnlyLinkSelectors } from '@osf/shared/stores'; +import { CustomConfirmationService, CustomDialogService, LoaderService, ToastService } from '@osf/shared/services'; +import { + CurrentResourceSelectors, + DeleteViewOnlyLink, + FetchViewOnlyLinks, + GetResource, + GetResourceWithChildren, + ViewOnlyLinkSelectors, +} from '@osf/shared/stores'; import { + DeleteProjectDialogComponent, ProjectSettingNotificationsComponent, SettingsAccessRequestsCardComponent, SettingsProjectAffiliationComponent, @@ -60,8 +69,8 @@ import { }) export class SettingsComponent implements OnInit { private readonly route = inject(ActivatedRoute); - private readonly router = inject(Router); private readonly customConfirmationService = inject(CustomConfirmationService); + private readonly customDialogService = inject(CustomDialogService); private readonly toastService = inject(ToastService); private readonly loaderService = inject(LoaderService); @@ -75,6 +84,10 @@ export class SettingsComponent implements OnInit { viewOnlyLinks = select(ViewOnlyLinkSelectors.getViewOnlyLinks); isViewOnlyLinksLoading = select(ViewOnlyLinkSelectors.isViewOnlyLinksLoading); currentUser = select(UserSelectors.getCurrentUser); + currentProject = select(CurrentResourceSelectors.getCurrentResource); + isMedium = toSignal(inject(IS_MEDIUM)); + + rootProjectId = computed(() => this.currentProject()?.rootResourceId); actions = createDispatchMap({ getSettings: GetProjectSettings, @@ -88,6 +101,7 @@ export class SettingsComponent implements OnInit { deleteProject: DeleteProject, deleteInstitution: DeleteInstitution, refreshCurrentResource: GetResource, + getComponentsTree: GetResourceWithChildren, }); accessRequest = signal(false); @@ -176,18 +190,13 @@ export class SettingsComponent implements OnInit { } deleteProject(): void { - this.customConfirmationService.confirmDelete({ - headerKey: 'project.deleteProject.title', - messageParams: { name: this.projectDetails().title }, - messageKey: 'project.deleteProject.message', - onConfirm: () => { - this.loaderService.show(); - this.actions.deleteProject(this.projectId()).subscribe(() => { - this.loaderService.hide(); - this.toastService.showSuccess('project.deleteProject.success'); - this.router.navigate(['/']); - }); - }, + const dialogWidth = this.isMedium() ? '500px' : '95vw'; + + this.actions.getComponentsTree(this.rootProjectId() || this.projectId(), this.projectId(), ResourceType.Project); + + this.customDialogService.open(DeleteProjectDialogComponent, { + header: 'project.deleteProject.dialog.deleteProject', + width: dialogWidth, }); } diff --git a/src/app/features/project/settings/store/settings.actions.ts b/src/app/features/project/settings/store/settings.actions.ts index ae0a30e0b..24ea4c787 100644 --- a/src/app/features/project/settings/store/settings.actions.ts +++ b/src/app/features/project/settings/store/settings.actions.ts @@ -1,5 +1,5 @@ import { SubscriptionFrequency } from '@osf/shared/enums'; -import { UpdateNodeRequestModel } from '@shared/models'; +import { NodeShortInfoModel, UpdateNodeRequestModel } from '@shared/models'; import { ProjectSettingsData } from '../models'; @@ -42,7 +42,7 @@ export class UpdateProjectNotificationSubscription { export class DeleteProject { static readonly type = '[Project Settings] Delete Project'; - constructor(public projectId: string) {} + constructor(public projects: NodeShortInfoModel[]) {} } export class DeleteInstitution { diff --git a/src/app/features/project/settings/store/settings.model.ts b/src/app/features/project/settings/store/settings.model.ts index 691010265..0b882c8c6 100644 --- a/src/app/features/project/settings/store/settings.model.ts +++ b/src/app/features/project/settings/store/settings.model.ts @@ -12,6 +12,7 @@ export const SETTINGS_STATE_DEFAULTS: SettingsStateModel = { settings: { data: {} as ProjectSettingsModel, isLoading: false, + isSubmitting: false, error: null, }, projectDetails: { diff --git a/src/app/features/project/settings/store/settings.selectors.ts b/src/app/features/project/settings/store/settings.selectors.ts index 4a46ce241..f7239a63a 100644 --- a/src/app/features/project/settings/store/settings.selectors.ts +++ b/src/app/features/project/settings/store/settings.selectors.ts @@ -21,6 +21,11 @@ export class SettingsSelectors { return state.projectDetails.isLoading; } + @Selector([SettingsState]) + static isSettingsSubmitting(state: SettingsStateModel): boolean { + return state.settings.isSubmitting || false; + } + @Selector([SettingsState]) static getNotificationSubscriptions(state: SettingsStateModel): NotificationSubscription[] { return state.notifications.data; diff --git a/src/app/features/project/settings/store/settings.state.ts b/src/app/features/project/settings/store/settings.state.ts index 1debaaad3..99d016cfa 100644 --- a/src/app/features/project/settings/store/settings.state.ts +++ b/src/app/features/project/settings/store/settings.state.ts @@ -186,7 +186,26 @@ export class SettingsState { @Action(DeleteProject) deleteProject(ctx: StateContext, action: DeleteProject) { - return this.settingsService.deleteProject(action.projectId); + const state = ctx.getState(); + + ctx.patchState({ + settings: { + ...state.settings, + isSubmitting: true, + }, + }); + + return this.settingsService.deleteProject(action.projects).pipe( + tap(() => { + ctx.patchState({ + settings: { + ...state.settings, + isSubmitting: false, + }, + }); + }), + catchError((error) => handleSectionError(ctx, 'settings', error)) + ); } @Action(DeleteInstitution) diff --git a/src/app/shared/mappers/nodes/base-node.mapper.ts b/src/app/shared/mappers/nodes/base-node.mapper.ts index bcfc0697f..b12ba91a2 100644 --- a/src/app/shared/mappers/nodes/base-node.mapper.ts +++ b/src/app/shared/mappers/nodes/base-node.mapper.ts @@ -10,6 +10,7 @@ export class BaseNodeMapper { id: item.id, title: item.attributes.title, parentId: item.relationships.parent?.data?.id, + permissions: item.attributes.current_user_permissions || [], })); } diff --git a/src/app/shared/models/current-resource.model.ts b/src/app/shared/models/current-resource.model.ts index c222a7a21..db1ab3faf 100644 --- a/src/app/shared/models/current-resource.model.ts +++ b/src/app/shared/models/current-resource.model.ts @@ -5,6 +5,7 @@ export interface CurrentResource { type: string; parentId?: string; parentType?: string; + rootResourceId?: string; wikiEnabled?: boolean; permissions: UserPermissions[]; } diff --git a/src/app/shared/models/guid-response-json-api.model.ts b/src/app/shared/models/guid-response-json-api.model.ts index 6e0d26f71..375488956 100644 --- a/src/app/shared/models/guid-response-json-api.model.ts +++ b/src/app/shared/models/guid-response-json-api.model.ts @@ -19,6 +19,9 @@ interface GuidDataJsonApi { provider?: { data: IdType; }; + root?: { + data: IdType; + }; }; } diff --git a/src/app/shared/models/nodes/node-with-children.model.ts b/src/app/shared/models/nodes/node-with-children.model.ts index 3fc1ed08d..8aac42031 100644 --- a/src/app/shared/models/nodes/node-with-children.model.ts +++ b/src/app/shared/models/nodes/node-with-children.model.ts @@ -2,4 +2,5 @@ export interface NodeShortInfoModel { id: string; title: string; parentId?: string; + permissions?: string[]; } diff --git a/src/app/shared/services/resource.service.ts b/src/app/shared/services/resource.service.ts index ff77bad0c..533e9b8ec 100644 --- a/src/app/shared/services/resource.service.ts +++ b/src/app/shared/services/resource.service.ts @@ -57,6 +57,7 @@ export class ResourceGuidService { : res.data.relationships.target?.data.type, wikiEnabled: res.data.attributes.wiki_enabled, permissions: res.data.attributes.current_user_permissions, + rootResourceId: res.data.relationships.root?.data?.id, }) as CurrentResource ), finalize(() => this.loaderService.hide()) diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index ab1952cb4..ea966de53 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1023,8 +1023,13 @@ } }, "deleteProject": { - "title": "Delete project", - "message": "Are you sure you want to delete {{name}} project?", + "dialog": { + "deleteProject": "Delete project and components", + "listMessage": "You are about to delete the following:", + "warningMessage": "It will no longer be available to you or any other contributors. This is irreversible.", + "confirmation": "Type the following to continue:", + "noPermissionsMessage": "You don not have permissions to delete this project and its components.

To delete, you must have admin permissions across all objects. Please check your permissions and try again.

Contact support at support@osf.io if you have any questions." + }, "success": "Project has been successfully deleted." }, "deleteInstitution": { From 23fb5ecd973ddf0e00b4463d3016cba2191f3deb Mon Sep 17 00:00:00 2001 From: Roman Nastyuk Date: Fri, 3 Oct 2025 13:49:06 +0300 Subject: [PATCH 3/3] fix(ang-734): fix affiliations bug --- .../add-component-dialog.component.html | 2 +- .../add-component-dialog.component.ts | 40 ++++++++++++++----- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.html b/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.html index 6fbc974cc..4bb7af2c4 100644 --- a/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.html +++ b/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.html @@ -14,7 +14,7 @@

diff --git a/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.ts b/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.ts index 8165c6e51..b592bf39b 100644 --- a/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.ts +++ b/src/app/features/project/overview/components/add-component-dialog/add-component-dialog.component.ts @@ -9,7 +9,7 @@ import { InputText } from 'primeng/inputtext'; import { Select } from 'primeng/select'; import { Textarea } from 'primeng/textarea'; -import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, effect, inject, OnInit, signal } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; @@ -47,6 +47,7 @@ export class AddComponentDialogComponent implements OnInit { destroyRef = inject(DestroyRef); ComponentFormControls = ComponentFormControls; + selectedInstitutions = signal([]); storageLocations = select(RegionsSelectors.getRegions); currentUser = select(UserSelectors.getCurrentUser); currentProject = select(ProjectOverviewSelectors.getProject); @@ -86,16 +87,7 @@ export class AddComponentDialogComponent implements OnInit { }); constructor() { - effect(() => { - const storageLocations = this.storageLocations(); - if (!storageLocations?.length) return; - - const storageLocationControl = this.componentForm.controls[ComponentFormControls.StorageLocation]; - if (!storageLocationControl.value) { - const defaultRegion = this.currentUser()?.defaultRegionId ?? storageLocations[0].id; - storageLocationControl.setValue(defaultRegion); - } - }); + this.setupEffects(); } ngOnInit(): void { @@ -142,4 +134,30 @@ export class AddComponentDialogComponent implements OnInit { }, }); } + + private setupEffects(): void { + effect(() => { + const storageLocations = this.storageLocations(); + if (!storageLocations?.length) return; + + const storageLocationControl = this.componentForm.controls[ComponentFormControls.StorageLocation]; + if (!storageLocationControl.value) { + const defaultRegion = this.currentUser()?.defaultRegionId ?? storageLocations[0].id; + storageLocationControl.setValue(defaultRegion); + } + }); + + effect(() => { + const projectInstitutions = this.currentProject()?.affiliatedInstitutions; + const userInstitutions = this.userInstitutions(); + + if (projectInstitutions && projectInstitutions.length && userInstitutions.length) { + const matchedInstitutions = projectInstitutions + .map((projInst) => userInstitutions.find((userInst) => userInst.id === projInst.id)) + .filter((inst) => inst !== undefined); + + this.selectedInstitutions.set(matchedInstitutions); + } + }); + } }