diff --git a/src/app/app.config.ts b/src/app/app.config.ts index ab079babd..4f619d1f6 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -15,7 +15,7 @@ import { STATES } from '@core/constants'; import { provideTranslation } from '@core/helpers'; import { GlobalErrorHandler } from './core/handlers'; -import { authInterceptor, errorInterceptor } from './core/interceptors'; +import { authInterceptor, errorInterceptor, viewOnlyInterceptor } from './core/interceptors'; import CustomPreset from './core/theme/custom-preset'; import { routes } from './app.routes'; @@ -37,7 +37,7 @@ export const appConfig: ApplicationConfig = { }, }), provideAnimations(), - provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])), + provideHttpClient(withInterceptors([authInterceptor, viewOnlyInterceptor, errorInterceptor])), importProvidersFrom(TranslateModule.forRoot(provideTranslation())), ConfirmationService, MessageService, diff --git a/src/app/core/components/nav-menu/nav-menu.component.html b/src/app/core/components/nav-menu/nav-menu.component.html index 9b7938bdf..b0442de6a 100644 --- a/src/app/core/components/nav-menu/nav-menu.component.html +++ b/src/app/core/components/nav-menu/nav-menu.component.html @@ -3,6 +3,7 @@ { const store = inject(Store); @@ -17,6 +18,10 @@ export const authGuard: CanActivateFn = () => { return true; } + if (hasViewOnlyParam(router)) { + return true; + } + return store.dispatch(GetCurrentUser).pipe( switchMap(() => { return store.select(UserSelectors.isAuthenticated).pipe( diff --git a/src/app/core/guards/index.ts b/src/app/core/guards/index.ts index 13807a9a8..98319a72e 100644 --- a/src/app/core/guards/index.ts +++ b/src/app/core/guards/index.ts @@ -2,3 +2,4 @@ export { authGuard } from './auth.guard'; export { isProjectGuard } from './is-project.guard'; export { isRegistryGuard } from './is-registry.guard'; export { redirectIfLoggedInGuard } from './redirect-if-logged-in.guard'; +export { viewOnlyGuard } from './view-only.guard'; diff --git a/src/app/core/guards/view-only.guard.ts b/src/app/core/guards/view-only.guard.ts new file mode 100644 index 000000000..964733c30 --- /dev/null +++ b/src/app/core/guards/view-only.guard.ts @@ -0,0 +1,37 @@ +import { inject } from '@angular/core'; +import { CanActivateFn, Router } from '@angular/router'; + +import { VIEW_ONLY_EXCLUDED_ROUTES } from '@core/constants/view-only-excluded-routes.const'; +import { hasViewOnlyParam } from '@osf/shared/helpers'; + +export const viewOnlyGuard: CanActivateFn = (route) => { + const router = inject(Router); + + if (!hasViewOnlyParam(router)) { + return true; + } + + const routePath = route.routeConfig?.path || ''; + + const isBlocked = VIEW_ONLY_EXCLUDED_ROUTES.some( + (blockedRoute) => routePath === blockedRoute || routePath.startsWith(`${blockedRoute}/`) + ); + + if (!isBlocked) { + return true; + } + + const urlSegments = router.url.split('/'); + const resourceId = urlSegments[1]; + const viewOnlyParam = new URLSearchParams(window.location.search).get('view_only'); + + if (resourceId && viewOnlyParam) { + router.navigate([resourceId, 'overview'], { + queryParams: { view_only: viewOnlyParam }, + }); + } else { + router.navigate(['/']); + } + + return false; +}; diff --git a/src/app/core/helpers/nav-menu.helper.ts b/src/app/core/helpers/nav-menu.helper.ts index 4620a5fad..306801165 100644 --- a/src/app/core/helpers/nav-menu.helper.ts +++ b/src/app/core/helpers/nav-menu.helper.ts @@ -1,10 +1,14 @@ import { MenuItem } from 'primeng/api'; +import { getViewOnlyParamFromUrl } from '@osf/shared/helpers'; + import { AUTHENTICATED_MENU_ITEMS, PREPRINT_MENU_ITEMS, PROJECT_MENU_ITEMS, REGISTRATION_MENU_ITEMS, + VIEW_ONLY_PROJECT_MENU_ITEMS, + VIEW_ONLY_REGISTRY_MENU_ITEMS, } from '../constants'; import { RouteContext } from '../models'; @@ -82,13 +86,21 @@ function updateProjectMenuItem(item: MenuItem, ctx: RouteContext): MenuItem { const items = (item.items || []).map((subItem) => { if (subItem.id === 'project-details') { if (hasProject) { + let menuItems = PROJECT_MENU_ITEMS; + + if (ctx.isViewOnly) { + const allowedViewOnlyItems = VIEW_ONLY_PROJECT_MENU_ITEMS; + menuItems = PROJECT_MENU_ITEMS.filter((menuItem) => allowedViewOnlyItems.includes(menuItem.id || '')); + } + return { ...subItem, visible: true, expanded: true, - items: PROJECT_MENU_ITEMS.map((menuItem) => ({ + items: menuItems.map((menuItem) => ({ ...menuItem, routerLink: [ctx.resourceId as string, menuItem.routerLink], + queryParams: ctx.isViewOnly ? { view_only: getViewOnlyParamFromUrl(ctx.currentUrl) } : undefined, })), }; } @@ -105,13 +117,21 @@ function updateRegistryMenuItem(item: MenuItem, ctx: RouteContext): MenuItem { const items = (item.items || []).map((subItem) => { if (subItem.id === 'registry-details') { if (hasRegistry) { + let menuItems = REGISTRATION_MENU_ITEMS; + + if (ctx.isViewOnly) { + const allowedViewOnlyItems = VIEW_ONLY_REGISTRY_MENU_ITEMS; + menuItems = REGISTRATION_MENU_ITEMS.filter((menuItem) => allowedViewOnlyItems.includes(menuItem.id || '')); + } + return { ...subItem, visible: true, expanded: true, - items: REGISTRATION_MENU_ITEMS.map((menuItem) => ({ + items: menuItems.map((menuItem) => ({ ...menuItem, routerLink: [ctx.resourceId as string, menuItem.routerLink], + queryParams: ctx.isViewOnly ? { view_only: getViewOnlyParamFromUrl(ctx.currentUrl) } : undefined, })), }; } @@ -135,6 +155,7 @@ function updatePreprintMenuItem(item: MenuItem, ctx: RouteContext): MenuItem { items: PREPRINT_MENU_ITEMS.map((menuItem) => ({ ...menuItem, routerLink: ['preprints', ctx.providerId, ctx.resourceId as string], + queryParams: ctx.isViewOnly ? { view_only: getViewOnlyParamFromUrl(ctx.currentUrl) } : undefined, })), }; } diff --git a/src/app/core/interceptors/error.interceptor.ts b/src/app/core/interceptors/error.interceptor.ts index 2cebd2f9a..6019e8c7e 100644 --- a/src/app/core/interceptors/error.interceptor.ts +++ b/src/app/core/interceptors/error.interceptor.ts @@ -5,6 +5,7 @@ import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http'; import { inject } from '@angular/core'; import { Router } from '@angular/router'; +import { hasViewOnlyParam } from '@osf/shared/helpers'; import { LoaderService, ToastService } from '@osf/shared/services'; import { ERROR_MESSAGES } from '../constants'; @@ -31,7 +32,9 @@ export const errorInterceptor: HttpInterceptorFn = (req, next) => { } if (error.status === 401) { - authService.logout(); + if (!hasViewOnlyParam(router)) { + authService.logout(); + } return throwError(() => error); } diff --git a/src/app/core/interceptors/index.ts b/src/app/core/interceptors/index.ts index 830718180..2f3e471eb 100644 --- a/src/app/core/interceptors/index.ts +++ b/src/app/core/interceptors/index.ts @@ -1,2 +1,3 @@ export * from './auth.interceptor'; export * from './error.interceptor'; +export * from './view-only.interceptor'; diff --git a/src/app/core/interceptors/view-only.interceptor.ts b/src/app/core/interceptors/view-only.interceptor.ts new file mode 100644 index 000000000..908913796 --- /dev/null +++ b/src/app/core/interceptors/view-only.interceptor.ts @@ -0,0 +1,29 @@ +import { Observable } from 'rxjs'; + +import { HttpEvent, HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { Router } from '@angular/router'; + +import { getViewOnlyParam } from '@osf/shared/helpers'; + +export const viewOnlyInterceptor: HttpInterceptorFn = ( + req: HttpRequest, + next: HttpHandlerFn +): Observable> => { + const router = inject(Router); + + const viewOnlyParam = getViewOnlyParam(router); + + if (!req.url.includes('/api.crossref.org/funders') && viewOnlyParam) { + const separator = req.url.includes('?') ? '&' : '?'; + const updatedUrl = `${req.url}${separator}view_only=${encodeURIComponent(viewOnlyParam)}`; + + const viewOnlyReq = req.clone({ + url: updatedUrl, + }); + + return next(viewOnlyReq); + } else { + return next(req); + } +}; diff --git a/src/app/core/models/route-context.model.ts b/src/app/core/models/route-context.model.ts index 780ac929b..636bcf291 100644 --- a/src/app/core/models/route-context.model.ts +++ b/src/app/core/models/route-context.model.ts @@ -7,4 +7,5 @@ export interface RouteContext { preprintReviewsPageVisible?: boolean; isCollections: boolean; currentUrl?: string; + isViewOnly?: boolean; } diff --git a/src/app/features/files/models/file-target.model.ts b/src/app/features/files/models/file-target.model.ts index 85858e99d..0917b8c43 100644 --- a/src/app/features/files/models/file-target.model.ts +++ b/src/app/features/files/models/file-target.model.ts @@ -20,4 +20,5 @@ export interface OsfFileTarget { wikiEnabled: boolean; public: boolean; type: string; + isAnonymous?: boolean; } diff --git a/src/app/features/files/pages/file-detail/file-detail.component.html b/src/app/features/files/pages/file-detail/file-detail.component.html index 9169ca030..fb947e530 100644 --- a/src/app/features/files/pages/file-detail/file-detail.component.html +++ b/src/app/features/files/pages/file-detail/file-detail.component.html @@ -18,11 +18,13 @@
- + @if (!isAnonymous()) { + + } @if (file()?.links?.download) { @@ -55,7 +57,7 @@
} - @if (file()) { + @if (file() && !isAnonymous()) { diff --git a/src/app/features/files/pages/file-detail/file-detail.component.ts b/src/app/features/files/pages/file-detail/file-detail.component.ts index c489c8a41..c03909b26 100644 --- a/src/app/features/files/pages/file-detail/file-detail.component.ts +++ b/src/app/features/files/pages/file-detail/file-detail.component.ts @@ -78,15 +78,16 @@ export class FileDetailComponent { file = select(FilesSelectors.getOpenedFile); isFileLoading = select(FilesSelectors.isOpenedFileLoading); + isAnonymous = select(FilesSelectors.isFilesAnonymous); safeLink: SafeResourceUrl | null = null; resourceId = ''; resourceType = ''; isIframeLoading = true; - protected readonly FileDetailTab = FileDetailTab; + readonly FileDetailTab = FileDetailTab; - protected selectedTab: FileDetailTab = FileDetailTab.Details; + selectedTab: FileDetailTab = FileDetailTab.Details; fileGuid = ''; @@ -189,27 +190,27 @@ export class FileDetailComponent { this.selectedTab = index; } - protected handleEmailShare(): void { + handleEmailShare(): void { const link = `mailto:?subject=${this.file()?.name ?? ''}&body=${this.file()?.links?.html ?? ''}`; window.location.href = link; } - protected handleXShare(): void { + handleXShare(): void { const link = `https://x.com/intent/tweet?url=${this.file()?.links?.html ?? ''}&text=${this.file()?.name ?? ''}&via=OSFramework`; window.open(link, '_blank', 'noopener,noreferrer'); } - protected handleFacebookShare(): void { + handleFacebookShare(): void { const link = `https://www.facebook.com/dialog/share?app_id=1022273774556662&display=popup&href=${this.file()?.links?.html ?? ''}&redirect_uri=${this.file()?.links?.html ?? ''}`; window.open(link, '_blank', 'noopener,noreferrer'); } - protected handleCopyDynamicEmbed(): void { + handleCopyDynamicEmbed(): void { const data = embedDynamicJs.replace('ENCODED_URL', this.file()?.links?.render ?? ''); this.copyToClipboard(data); } - protected handleCopyStaticEmbed(): void { + handleCopyStaticEmbed(): void { const data = embedStaticHtml.replace('ENCODED_URL', this.file()?.links?.render ?? ''); this.copyToClipboard(data); } diff --git a/src/app/features/files/pages/files/files.component.html b/src/app/features/files/pages/files/files.component.html index 6980c972c..7841a8a30 100644 --- a/src/app/features/files/pages/files/files.component.html +++ b/src/app/features/files/pages/files/files.component.html @@ -4,6 +4,10 @@ } @else {
+ @if (hasViewOnly()) { + + } +
- @if (!isViewOnly()) { + @if (!isViewOnly() && !hasViewOnly()) { { + return hasViewOnlyParam(this.router); + }); readonly files = select(FilesSelectors.getFiles); readonly isFilesLoading = select(FilesSelectors.isFilesLoading); readonly currentFolder = select(FilesSelectors.getCurrentFolder); diff --git a/src/app/features/files/store/files.model.ts b/src/app/features/files/store/files.model.ts index 8b0cf19af..1d21983ff 100644 --- a/src/app/features/files/store/files.model.ts +++ b/src/app/features/files/store/files.model.ts @@ -21,6 +21,7 @@ export interface FilesStateModel { tags: AsyncStateModel; rootFolders: AsyncStateModel; configuredStorageAddons: AsyncStateModel; + isAnonymous: boolean; } export const filesStateDefaults: FilesStateModel = { @@ -79,4 +80,5 @@ export const filesStateDefaults: FilesStateModel = { isLoading: true, error: null, }, + isAnonymous: false, }; diff --git a/src/app/features/files/store/files.selectors.ts b/src/app/features/files/store/files.selectors.ts index 4d1c103cf..b7f8b8f47 100644 --- a/src/app/features/files/store/files.selectors.ts +++ b/src/app/features/files/store/files.selectors.ts @@ -18,6 +18,11 @@ export class FilesSelectors { return state.files.isLoading; } + @Selector([FilesState]) + static isFilesAnonymous(state: FilesStateModel): boolean { + return state.isAnonymous; + } + @Selector([FilesState]) static getMoveFileFiles(state: FilesStateModel): OsfFile[] { return state.moveFileFiles.data; diff --git a/src/app/features/files/store/files.state.ts b/src/app/features/files/store/files.state.ts index 61d6217bb..805b04681 100644 --- a/src/app/features/files/store/files.state.ts +++ b/src/app/features/files/store/files.state.ts @@ -52,13 +52,14 @@ export class FilesState { return this.filesService.getFiles(action.filesLink, '', '').pipe( tap({ - next: (files) => { + next: (response) => { ctx.patchState({ moveFileFiles: { - data: files, + data: response.files, isLoading: false, error: null, }, + isAnonymous: response.meta?.anonymous ?? false, }); }, }), @@ -72,13 +73,14 @@ export class FilesState { ctx.patchState({ files: { ...state.files, isLoading: true, error: null } }); return this.filesService.getFiles(action.filesLink, state.search, state.sort).pipe( tap({ - next: (files) => { + next: (response) => { ctx.patchState({ files: { - data: files, + data: response.files, isLoading: false, error: null, }, + isAnonymous: response.meta?.anonymous ?? false, }); }, }), @@ -280,13 +282,14 @@ export class FilesState { return this.filesService.getFolders(action.folderLink).pipe( tap({ - next: (folders) => + next: (response) => ctx.patchState({ rootFolders: { - data: folders, + data: response.files, isLoading: false, error: null, }, + isAnonymous: response.meta?.anonymous ?? false, }), }), catchError((error) => handleSectionError(ctx, 'rootFolders', error)) diff --git a/src/app/features/my-projects/mappers/my-resources.mapper.ts b/src/app/features/my-projects/mappers/my-resources.mapper.ts index 18c79f9fe..8fafdb4dc 100644 --- a/src/app/features/my-projects/mappers/my-resources.mapper.ts +++ b/src/app/features/my-projects/mappers/my-resources.mapper.ts @@ -9,12 +9,13 @@ export class MyResourcesMapper { dateCreated: response.attributes.date_created, dateModified: response.attributes.date_modified, isPublic: response.attributes.public, - contributors: response.embeds.bibliographic_contributors.data.map((contributor) => ({ - familyName: contributor.embeds.users.data.attributes.family_name, - fullName: contributor.embeds.users.data.attributes.full_name, - givenName: contributor.embeds.users.data.attributes.given_name, - middleName: contributor.embeds.users.data.attributes.middle_name, - })), + contributors: + response.embeds?.bibliographic_contributors?.data?.map((contributor) => ({ + familyName: contributor.embeds.users.data.attributes.family_name, + fullName: contributor.embeds.users.data.attributes.full_name, + givenName: contributor.embeds.users.data.attributes.given_name, + middleName: contributor.embeds.users.data.attributes.middle_name, + })) ?? [], }; } } diff --git a/src/app/features/project/analytics/analytics.component.html b/src/app/features/project/analytics/analytics.component.html index 33a9a5bcd..75a6a3274 100644 --- a/src/app/features/project/analytics/analytics.component.html +++ b/src/app/features/project/analytics/analytics.component.html @@ -1,6 +1,10 @@
+ @if (hasViewOnly()) { + + } +
diff --git a/src/app/features/project/analytics/analytics.component.ts b/src/app/features/project/analytics/analytics.component.ts index 3981f063e..984884927 100644 --- a/src/app/features/project/analytics/analytics.component.ts +++ b/src/app/features/project/analytics/analytics.component.ts @@ -7,15 +7,25 @@ import { SelectModule } from 'primeng/select'; import { map, of } from 'rxjs'; import { CommonModule, DatePipe } from '@angular/common'; -import { ChangeDetectionStrategy, Component, DestroyRef, inject, OnInit, Signal, signal } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + inject, + OnInit, + Signal, + signal, +} from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { BarChartComponent, LineChartComponent, PieChartComponent, SubHeaderComponent } from '@osf/shared/components'; import { ResourceType } from '@osf/shared/enums'; -import { IS_WEB } from '@osf/shared/helpers'; +import { hasViewOnlyParam, IS_WEB } from '@osf/shared/helpers'; import { DatasetInput } from '@osf/shared/models'; +import { ViewOnlyLinkMessageComponent } from '@shared/components/view-only-link-message/view-only-link-message.component'; import { AnalyticsKpiComponent } from './components'; import { DATE_RANGE_OPTIONS } from './constants'; @@ -34,6 +44,7 @@ import { AnalyticsSelectors, ClearAnalytics, GetMetrics, GetRelatedCounts } from LineChartComponent, PieChartComponent, BarChartComponent, + ViewOnlyLinkMessageComponent, ], templateUrl: './analytics.component.html', styleUrl: './analytics.component.scss', @@ -41,10 +52,10 @@ import { AnalyticsSelectors, ClearAnalytics, GetMetrics, GetRelatedCounts } from providers: [DatePipe], }) export class AnalyticsComponent implements OnInit { - protected rangeOptions = DATE_RANGE_OPTIONS; - protected selectedRange = signal(DATE_RANGE_OPTIONS[0]); + rangeOptions = DATE_RANGE_OPTIONS; + selectedRange = signal(DATE_RANGE_OPTIONS[0]); - protected readonly IS_X_LARGE = toSignal(inject(IS_WEB)); + readonly IS_X_LARGE = toSignal(inject(IS_WEB)); private readonly datePipe = inject(DatePipe); private readonly route = inject(ActivatedRoute); @@ -56,31 +67,35 @@ export class AnalyticsComponent implements OnInit { this.route.data.pipe(map((params) => params['resourceType'])) ?? of(undefined) ); - protected analytics = select(AnalyticsSelectors.getMetrics(this.resourceId())); - protected relatedCounts = select(AnalyticsSelectors.getRelatedCounts(this.resourceId())); + hasViewOnly = computed(() => { + return hasViewOnlyParam(this.router); + }); + + analytics = select(AnalyticsSelectors.getMetrics(this.resourceId())); + relatedCounts = select(AnalyticsSelectors.getRelatedCounts(this.resourceId())); - protected isMetricsLoading = select(AnalyticsSelectors.isMetricsLoading); - protected isRelatedCountsLoading = select(AnalyticsSelectors.isRelatedCountsLoading); + isMetricsLoading = select(AnalyticsSelectors.isMetricsLoading); + isRelatedCountsLoading = select(AnalyticsSelectors.isRelatedCountsLoading); - protected isMetricsError = select(AnalyticsSelectors.isMetricsError); + isMetricsError = select(AnalyticsSelectors.isMetricsError); - protected actions = createDispatchMap({ + actions = createDispatchMap({ getMetrics: GetMetrics, getRelatedCounts: GetRelatedCounts, clearAnalytics: ClearAnalytics, }); - protected visitsLabels: string[] = []; - protected visitsDataset: DatasetInput[] = []; + visitsLabels: string[] = []; + visitsDataset: DatasetInput[] = []; - protected totalVisitsLabels: string[] = []; - protected totalVisitsDataset: DatasetInput[] = []; + totalVisitsLabels: string[] = []; + totalVisitsDataset: DatasetInput[] = []; - protected topReferrersLabels: string[] = []; - protected topReferrersDataset: DatasetInput[] = []; + topReferrersLabels: string[] = []; + topReferrersDataset: DatasetInput[] = []; - protected popularPagesLabels: string[] = []; - protected popularPagesDataset: DatasetInput[] = []; + popularPagesLabels: string[] = []; + popularPagesDataset: DatasetInput[] = []; ngOnInit() { this.actions.getMetrics(this.resourceId(), this.selectedRange().value); @@ -123,7 +138,7 @@ export class AnalyticsComponent implements OnInit { this.popularPagesDataset = [{ label: 'Popular pages', data: analytics.popularPages.map((item) => item.count) }]; } - protected navigateToDuplicates() { + navigateToDuplicates() { this.router.navigate(['duplicates'], { relativeTo: this.route }); } } diff --git a/src/app/features/project/analytics/components/view-duplicates/view-duplicates.component.ts b/src/app/features/project/analytics/components/view-duplicates/view-duplicates.component.ts index d41d472cc..bbde88952 100644 --- a/src/app/features/project/analytics/components/view-duplicates/view-duplicates.component.ts +++ b/src/app/features/project/analytics/components/view-duplicates/view-duplicates.component.ts @@ -67,18 +67,20 @@ export class ViewDuplicatesComponent { private route = inject(ActivatedRoute); private router = inject(Router); private destroyRef = inject(DestroyRef); - protected currentPage = signal('1'); - protected isSmall = toSignal(inject(IS_SMALL)); - protected readonly pageSize = 10; - protected readonly UserPermissions = UserPermissions; - protected firstIndex = computed(() => (parseInt(this.currentPage()) - 1) * this.pageSize); private project = select(ProjectOverviewSelectors.getProject); private registration = select(RegistryOverviewSelectors.getRegistry); - protected duplicates = select(DuplicatesSelectors.getDuplicates); - protected isDuplicatesLoading = select(DuplicatesSelectors.getDuplicatesLoading); - protected totalDuplicates = select(DuplicatesSelectors.getDuplicatesTotalCount); - - protected readonly forkActionItems = (resourceId: string) => [ + private isProjectAnonymous = select(ProjectOverviewSelectors.isProjectAnonymous); + private isRegistryAnonymous = select(RegistryOverviewSelectors.isRegistryAnonymous); + duplicates = select(DuplicatesSelectors.getDuplicates); + isDuplicatesLoading = select(DuplicatesSelectors.getDuplicatesLoading); + totalDuplicates = select(DuplicatesSelectors.getDuplicatesTotalCount); + readonly pageSize = 10; + readonly UserPermissions = UserPermissions; + currentPage = signal('1'); + isSmall = toSignal(inject(IS_SMALL)); + firstIndex = computed(() => (parseInt(this.currentPage()) - 1) * this.pageSize); + + readonly forkActionItems = (resourceId: string) => [ { label: 'project.overview.actions.manageContributors', command: () => this.router.navigate([resourceId, 'contributors']), @@ -109,7 +111,7 @@ export class ViewDuplicatesComponent { return null; }); - protected actions = createDispatchMap({ + actions = createDispatchMap({ getProject: GetProjectById, getRegistration: GetRegistryById, getDuplicates: GetAllDuplicates, @@ -140,10 +142,13 @@ export class ViewDuplicatesComponent { this.setupCleanup(); } - protected toolbarResource = computed(() => { + toolbarResource = computed(() => { const resource = this.currentResource(); const resourceType = this.resourceType(); if (resource && resourceType) { + const isAnonymous = + resourceType === ResourceType.Project ? this.isProjectAnonymous() : this.isRegistryAnonymous(); + return { id: resource.id, isPublic: resource.isPublic, @@ -151,12 +156,13 @@ export class ViewDuplicatesComponent { viewOnlyLinksCount: 0, forksCount: resource.forksCount, resourceType: resourceType, + isAnonymous, } as ToolbarResource; } return null; }); - protected handleForkResource(): void { + handleForkResource(): void { const toolbarResource = this.toolbarResource(); const dialogWidth = !this.isSmall() ? '95vw' : '450px'; diff --git a/src/app/features/project/contributors/components/create-view-link-dialog/create-view-link-dialog.component.html b/src/app/features/project/contributors/components/create-view-link-dialog/create-view-link-dialog.component.html index 468a6bc21..684d72f9a 100644 --- a/src/app/features/project/contributors/components/create-view-link-dialog/create-view-link-dialog.component.html +++ b/src/app/features/project/contributors/components/create-view-link-dialog/create-view-link-dialog.component.html @@ -29,21 +29,49 @@

-
- @for (item of config.data.sharedComponents; track item.id) { -
- - -

{{ item.title }}

-
- } -
+ @if (isLoading()) { + + } @else { +
+ @for (item of allComponents; track item.id) { +
+ + +

+ {{ item.title }} + @if (isCurrentProject(item)) { + + {{ 'myProjects.settings.viewOnlyLinkCurrentProject' | translate }} + + } +

+
+ } + @if (allComponents.length > 1) { +
+ + +
+ } +
+ }
diff --git a/src/app/features/project/contributors/components/create-view-link-dialog/create-view-link-dialog.component.ts b/src/app/features/project/contributors/components/create-view-link-dialog/create-view-link-dialog.component.ts index d22811fbb..772abb953 100644 --- a/src/app/features/project/contributors/components/create-view-link-dialog/create-view-link-dialog.component.ts +++ b/src/app/features/project/contributors/components/create-view-link-dialog/create-view-link-dialog.component.ts @@ -1,20 +1,31 @@ +import { createDispatchMap, select } from '@ngxs/store'; + import { TranslatePipe } from '@ngx-translate/core'; import { Button } from 'primeng/button'; import { Checkbox } from 'primeng/checkbox'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; -import { ChangeDetectionStrategy, Component, inject, OnInit, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, effect, inject, OnInit, signal } from '@angular/core'; import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { TextInputComponent } from '@osf/shared/components'; +import { GetComponents, ProjectOverviewSelectors } from '@osf/features/project/overview/store'; +import { LoadingSpinnerComponent, TextInputComponent } from '@osf/shared/components'; import { InputLimits } from '@osf/shared/constants'; import { CustomValidators } from '@osf/shared/helpers'; -import { ViewOnlyLinkNodeModel } from '@osf/shared/models'; +import { ViewOnlyLinkComponent } from '@shared/models'; @Component({ selector: 'osf-create-view-link-dialog', - imports: [Button, TranslatePipe, ReactiveFormsModule, FormsModule, Checkbox, TextInputComponent], + imports: [ + Button, + TranslatePipe, + ReactiveFormsModule, + FormsModule, + Checkbox, + TextInputComponent, + LoadingSpinnerComponent, + ], templateUrl: './create-view-link-dialog.component.html', styleUrl: './create-view-link-dialog.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -28,54 +39,129 @@ export class CreateViewLinkDialogComponent implements OnInit { anonymous = signal(true); protected selectedComponents = signal>({}); - readonly projectId = signal(''); + protected components = select(ProjectOverviewSelectors.getComponents); + protected isLoading = select(ProjectOverviewSelectors.getComponentsLoading); + + protected actions = createDispatchMap({ + getComponents: GetComponents, + }); + + get currentProjectId(): string { + return this.config.data?.['projectId'] || ''; + } + + get allComponents(): ViewOnlyLinkComponent[] { + const currentProjectData = this.config.data?.['currentProject']; + const components = this.components(); + + const result: ViewOnlyLinkComponent[] = []; + + if (currentProjectData) { + result.push({ + id: currentProjectData.id, + title: currentProjectData.title, + isCurrentProject: true, + }); + } + + components.forEach((comp) => { + result.push({ + id: comp.id, + title: comp.title, + isCurrentProject: false, + }); + }); + + return result; + } + + constructor() { + effect(() => { + const components = this.allComponents; + if (components.length) { + this.initializeSelection(); + } + }); + } ngOnInit(): void { - const data = (this.config.data?.['sharedComponents'] as ViewOnlyLinkNodeModel[]) || []; - this.projectId.set(this.config.data?.projectId); - const initialState = data.reduce( - (acc, curr) => { - if (curr.id) { - acc[curr.id] = true; - } - return acc; - }, - {} as Record - ); + const projectId = this.currentProjectId; + + if (projectId) { + this.actions.getComponents(projectId); + } else { + this.initializeSelection(); + } + } + + private initializeSelection(): void { + const initialState: Record = {}; + + this.allComponents.forEach((component) => { + initialState[component.id] = component.isCurrentProject; + }); + this.selectedComponents.set(initialState); } - isCurrentProject(item: ViewOnlyLinkNodeModel): boolean { - return item.category === 'project' && item.id === this.projectId(); + isCurrentProject(item: ViewOnlyLinkComponent): boolean { + return item.isCurrentProject; + } + + get isFormValid(): boolean { + return this.linkName.valid && !!this.linkName.value.trim().length; } addLink(): void { - if (!this.linkName.value) return; + if (!this.isFormValid) return; - const components = (this.config.data?.['sharedComponents'] as ViewOnlyLinkNodeModel[]) || []; - const selectedIds = Object.entries(this.selectedComponents()).filter(([_, checked]) => checked); + const selectedIds = Object.entries(this.selectedComponents()) + .filter(([, checked]) => checked) + .map(([id]) => id); - const selected = components - .filter((comp: ViewOnlyLinkNodeModel) => - selectedIds.find(([id, checked]: [string, boolean]) => id === comp.id && checked) - ) - .map((comp) => ({ - id: comp.id, - type: 'nodes', - })); + const rootProjectId = this.currentProjectId; + const rootProject = selectedIds.includes(rootProjectId) ? [{ id: rootProjectId, type: 'nodes' }] : []; + + const relationshipComponents = selectedIds + .filter((id) => id !== rootProjectId) + .map((id) => ({ id, type: 'nodes' })); - const data = { + const data: Record = { attributes: { name: this.linkName.value, anonymous: this.anonymous(), }, - nodes: selected, + nodes: rootProject, }; + if (relationshipComponents.length) { + data['relationships'] = { + nodes: { + data: relationshipComponents, + }, + }; + } + this.dialogRef.close(data); } onCheckboxToggle(id: string, checked: boolean): void { this.selectedComponents.update((prev) => ({ ...prev, [id]: checked })); } + + selectAllComponents(): void { + const allIds: Record = {}; + this.allComponents.forEach((component) => { + allIds[component.id] = true; + }); + this.selectedComponents.set(allIds); + } + + deselectAllComponents(): void { + const allIds: Record = {}; + this.allComponents.forEach((component) => { + allIds[component.id] = component.isCurrentProject; + }); + this.selectedComponents.set(allIds); + } } diff --git a/src/app/features/project/contributors/contributors.component.html b/src/app/features/project/contributors/contributors.component.html index c03f81f75..5bed0121b 100644 --- a/src/app/features/project/contributors/contributors.component.html +++ b/src/app/features/project/contributors/contributors.component.html @@ -89,7 +89,12 @@

{{ 'project.contributors.viewOnly' | translate }}

{{ 'project.contributors.createLink' | translate }}

- + (null); protected readonly selectedBibliography = signal(null); @@ -96,6 +108,11 @@ export class ContributorsComponent implements OnInit { protected readonly isContributorsLoading = select(ContributorsSelectors.isContributorsLoading); protected readonly isViewOnlyLinksLoading = select(ViewOnlyLinkSelectors.isViewOnlyLinksLoading); + canCreateViewLink = computed(() => { + const details = this.projectDetails(); + return !!details && !!details.attributes && !!this.resourceId(); + }); + protected actions = createDispatchMap({ getViewOnlyLinks: FetchViewOnlyLinks, getResourceDetails: GetResourceDetails, @@ -108,6 +125,7 @@ export class ContributorsComponent implements OnInit { addContributor: AddContributor, createViewOnlyLink: CreateViewOnlyLink, deleteViewOnlyLink: DeleteViewOnlyLink, + getComponents: GetComponents, }); get hasChanges(): boolean { @@ -133,6 +151,7 @@ export class ContributorsComponent implements OnInit { this.actions.getViewOnlyLinks(id, this.resourceType()); this.actions.getResourceDetails(id, this.resourceType()); this.actions.getContributors(id, this.resourceType()); + this.actions.getComponents(id); } this.setSearchSubscription(); @@ -249,20 +268,23 @@ export class ContributorsComponent implements OnInit { } createViewLink() { - const sharedComponents = [ - { - id: this.projectDetails().id, - title: this.projectDetails().attributes.title, - category: 'project', - }, - ]; + const projectDetails = this.projectDetails(); + const projectId = this.resourceId(); + + const currentProject = { + id: projectDetails.id, + title: projectDetails.attributes.title, + }; this.dialogService .open(CreateViewLinkDialogComponent, { width: '448px', focusOnShow: false, header: this.translateService.instant('project.contributors.createLinkDialog.dialogTitle'), - data: { sharedComponents, projectId: this.resourceId() }, + data: { + projectId: projectId, + currentProject: currentProject, + }, closeOnEscape: true, modal: true, closable: true, @@ -285,7 +307,7 @@ export class ContributorsComponent implements OnInit { onConfirm: () => this.actions .deleteViewOnlyLink(this.resourceId(), this.resourceType(), link.id) - .subscribe(() => this.toastService.showSuccess('myProjects.settings.delete.success')), + .subscribe(() => this.toastService.showSuccess('myProjects.settings.viewOnlyLinkDeleted')), }); } } 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 557a7f29f..4bfe7bf31 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 @@ -39,9 +39,9 @@ (ResourceType.Project); searchControl = new FormControl(''); - skeletonData: MyResourcesItem[] = Array.from({ length: 10 }, () => ({}) as MyResourcesItem); + skeletonData: MyResourcesItem[] = Array.from({ length: this.tableRows }, () => ({}) as MyResourcesItem); currentProject = select(ProjectOverviewSelectors.getProject); myProjects = select(MyResourcesSelectors.getProjects); diff --git a/src/app/features/project/overview/components/overview-collections/overview-collections.component.html b/src/app/features/project/overview/components/overview-collections/overview-collections.component.html index 18120dbfc..df41395ef 100644 --- a/src/app/features/project/overview/components/overview-collections/overview-collections.component.html +++ b/src/app/features/project/overview/components/overview-collections/overview-collections.component.html @@ -2,7 +2,7 @@ @let submissions = projectSubmissions(); @if (project) { -

{{ 'project.overview.metadata.collection' | translate }}

+

{{ 'project.overview.metadata.collection' | translate }}

@if (isProjectSubmissionsLoading()) { } @else { diff --git a/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.html b/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.html index b93911a05..4267d9a10 100644 --- a/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.html +++ b/src/app/features/project/overview/components/overview-toolbar/overview-toolbar.component.html @@ -18,7 +18,7 @@
} - @if (isCollectionsRoute()) { + @if (isCollectionsRoute() || hasViewOnly()) { @if (isPublic()) {
@@ -46,14 +46,14 @@ class="flex" [pTooltip]="'project.overview.tooltips.viewOnlyLinks' | translate" tooltipPosition="bottom" - [routerLink]="[resource.id, 'settings']" + [routerLink]="'../contributors'" > {{ resource.viewOnlyLinksCount }} } - @if (resource.resourceType === ResourceType.Project) { + @if (resource.resourceType === ResourceType.Project && !hasViewOnly()) { } - - @if (!isBookmarksLoading() && !isBookmarksSubmitting()) { - - } - + @if (!hasViewOnly()) { + + @if (!isBookmarksLoading() && !isBookmarksSubmitting()) { + + } + + } - @if (resource.isPublic) { + @if (resource.isPublic && !hasViewOnly()) { { + return hasViewOnlyParam(this.router); + }); + readonly forkActionItems = [ { label: 'project.overview.actions.forkProject', diff --git a/src/app/features/project/overview/components/recent-activity/recent-activity.component.ts b/src/app/features/project/overview/components/recent-activity/recent-activity.component.ts index 0d9d88212..e8858d724 100644 --- a/src/app/features/project/overview/components/recent-activity/recent-activity.component.ts +++ b/src/app/features/project/overview/components/recent-activity/recent-activity.component.ts @@ -25,17 +25,17 @@ export class RecentActivityComponent { private readonly route = inject(ActivatedRoute); readonly pageSize = input.required(); - protected currentPage = signal(1); - protected activityLogs = select(ActivityLogsSelectors.getActivityLogs); - protected totalCount = select(ActivityLogsSelectors.getActivityLogsTotalCount); - protected isLoading = select(ActivityLogsSelectors.getActivityLogsLoading); - protected firstIndex = computed(() => (this.currentPage() - 1) * this.pageSize()); + currentPage = signal(1); + activityLogs = select(ActivityLogsSelectors.getActivityLogs); + totalCount = select(ActivityLogsSelectors.getActivityLogsTotalCount); + isLoading = select(ActivityLogsSelectors.getActivityLogsLoading); + firstIndex = computed(() => (this.currentPage() - 1) * this.pageSize()); - protected actions = createDispatchMap({ + actions = createDispatchMap({ getActivityLogs: GetActivityLogs, }); - protected formattedActivityLogs = computed(() => { + formattedActivityLogs = computed(() => { const logs = this.activityLogs(); return logs.map((log) => ({ ...log, diff --git a/src/app/features/project/overview/mappers/project-overview.mapper.ts b/src/app/features/project/overview/mappers/project-overview.mapper.ts index 7488be1d0..71028fc32 100644 --- a/src/app/features/project/overview/mappers/project-overview.mapper.ts +++ b/src/app/features/project/overview/mappers/project-overview.mapper.ts @@ -1,10 +1,10 @@ import { InstitutionsMapper } from '@shared/mappers'; import { License } from '@shared/models'; -import { ProjectOverview, ProjectOverviewGetResponseJsoApi } from '../models'; +import { ProjectOverview, ProjectOverviewGetResponseJsonApi } from '../models'; export class ProjectOverviewMapper { - static fromGetProjectResponse(response: ProjectOverviewGetResponseJsoApi): ProjectOverview { + static fromGetProjectResponse(response: ProjectOverviewGetResponseJsonApi): ProjectOverview { return { id: response.id, type: response.type, @@ -37,15 +37,18 @@ export class ProjectOverviewMapper { wikiEnabled: response.attributes.wiki_enabled, customCitation: response.attributes.custom_citation, subjects: response.attributes.subjects?.map((subjectArray) => subjectArray[0]), - contributors: response.embeds.bibliographic_contributors.data.map((contributor) => ({ - id: contributor.embeds.users.data.id, - familyName: contributor.embeds.users.data.attributes.family_name, - fullName: contributor.embeds.users.data.attributes.full_name, - givenName: contributor.embeds.users.data.attributes.given_name, - middleName: contributor.embeds.users.data.attributes.middle_name, - type: contributor.embeds.users.data.type, - })), - affiliatedInstitutions: InstitutionsMapper.fromInstitutionsResponse(response.embeds.affiliated_institutions), + contributors: + response.embeds.bibliographic_contributors?.data?.map((contributor) => ({ + id: contributor.embeds.users.data.id, + familyName: contributor.embeds.users.data.attributes.family_name, + fullName: contributor.embeds.users.data.attributes.full_name, + givenName: contributor.embeds.users.data.attributes.given_name, + middleName: contributor.embeds.users.data.attributes.middle_name, + type: contributor.embeds.users.data.type, + })) ?? [], + affiliatedInstitutions: response.embeds.affiliated_institutions + ? InstitutionsMapper.fromInstitutionsResponse(response.embeds.affiliated_institutions) + : [], identifiers: response.embeds.identifiers?.data.map((identifier) => ({ id: identifier.id, type: identifier.type, @@ -69,8 +72,8 @@ export class ProjectOverviewMapper { url: preprint.links.html, })), region: response.relationships.region?.data, - forksCount: response.relationships.forks.links.related.meta.count, - viewOnlyLinksCount: response.relationships.view_only_links.links.related.meta.count, + forksCount: response.relationships.forks?.links?.related?.meta?.count ?? 0, + viewOnlyLinksCount: response.relationships.view_only_links?.links?.related?.meta?.count ?? 0, links: { rootFolder: response.relationships?.files?.links?.related?.href, iri: response.links?.iri, diff --git a/src/app/features/project/overview/models/project-overview.models.ts b/src/app/features/project/overview/models/project-overview.models.ts index cf67e88ec..384b5865f 100644 --- a/src/app/features/project/overview/models/project-overview.models.ts +++ b/src/app/features/project/overview/models/project-overview.models.ts @@ -1,5 +1,11 @@ import { UserPermissions } from '@osf/shared/enums'; -import { Institution, InstitutionsJsonApiResponse, JsonApiResponse, License } from '@osf/shared/models'; +import { + Institution, + InstitutionsJsonApiResponse, + JsonApiResponseWithMeta, + License, + MetaAnonymousJsonApi, +} from '@osf/shared/models'; export interface ProjectOverviewContributor { familyName: string; @@ -67,7 +73,12 @@ export interface ProjectOverviewSubject { text: string; } -export interface ProjectOverviewGetResponseJsoApi { +export interface ProjectOverviewWithMeta { + project: ProjectOverview; + meta?: MetaAnonymousJsonApi; +} + +export interface ProjectOverviewGetResponseJsonApi { id: string; type: string; attributes: { @@ -204,8 +215,10 @@ export interface ProjectOverviewGetResponseJsoApi { }; } -export interface ProjectOverviewResponseJsonApi extends JsonApiResponse { - data: ProjectOverviewGetResponseJsoApi; +export interface ProjectOverviewResponseJsonApi + extends JsonApiResponseWithMeta { + data: ProjectOverviewGetResponseJsonApi; + meta: MetaAnonymousJsonApi; } export interface ProjectIdentifiers { diff --git a/src/app/features/project/overview/project-overview.component.html b/src/app/features/project/overview/project-overview.component.html index 3206026c9..079045d2b 100644 --- a/src/app/features/project/overview/project-overview.component.html +++ b/src/app/features/project/overview/project-overview.component.html @@ -70,6 +70,10 @@ } } + @if (hasViewOnly()) { + + } +
@if (isAdmin || canWrite) { diff --git a/src/app/features/project/overview/project-overview.component.ts b/src/app/features/project/overview/project-overview.component.ts index 894ff6ab0..815571b96 100644 --- a/src/app/features/project/overview/project-overview.component.ts +++ b/src/app/features/project/overview/project-overview.component.ts @@ -25,33 +25,33 @@ import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { SubmissionReviewStatus } from '@osf/features/moderation/enums'; -import { IS_XSMALL } from '@osf/shared/helpers'; import { - LoadingSpinnerComponent, - MakeDecisionDialogComponent, - ResourceMetadataComponent, - SubHeaderComponent, -} from '@shared/components'; -import { DataciteTrackerComponent } from '@shared/components/datacite-tracker/datacite-tracker.component'; -import { Mode, ResourceType, UserPermissions } from '@shared/enums'; -import { MapProjectOverview } from '@shared/mappers/resource-overview.mappers'; -import { ToastService } from '@shared/services'; + ClearCollectionModeration, + CollectionsModerationSelectors, + GetSubmissionsReviewActions, +} from '@osf/features/moderation/store/collections-moderation'; +import { Mode, ResourceType, UserPermissions } from '@osf/shared/enums'; +import { hasViewOnlyParam, IS_XSMALL } from '@osf/shared/helpers'; +import { MapProjectOverview } from '@osf/shared/mappers'; +import { ToastService } from '@osf/shared/services'; import { + ClearCollections, ClearWiki, CollectionsSelectors, GetBookmarksCollectionId, GetCollectionProvider, GetHomeWiki, GetLinkedResources, -} from '@shared/stores'; -import { GetActivityLogs } from '@shared/stores/activity-logs'; -import { ClearCollections } from '@shared/stores/collections'; - +} from '@osf/shared/stores'; +import { GetActivityLogs } from '@osf/shared/stores/activity-logs'; import { - ClearCollectionModeration, - CollectionsModerationSelectors, - GetSubmissionsReviewActions, -} from '../../moderation/store/collections-moderation'; + DataciteTrackerComponent, + LoadingSpinnerComponent, + MakeDecisionDialogComponent, + ResourceMetadataComponent, + SubHeaderComponent, + ViewOnlyLinkMessageComponent, +} from '@shared/components'; import { LinkedResourcesComponent, @@ -88,6 +88,8 @@ import { TranslatePipe, Message, RouterLink, + ViewOnlyLinkMessageComponent, + ViewOnlyLinkMessageComponent, ], providers: [DialogService], changeDetection: ChangeDetectionStrategy.OnPush, @@ -143,7 +145,7 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement return this.currentReviewAction()?.toState; }); - protected showDecisionButton = computed(() => { + showDecisionButton = computed(() => { return ( this.isCollectionsRoute() && this.submissionReviewStatus() !== SubmissionReviewStatus.Removed && @@ -151,12 +153,18 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement ); }); - protected currentProject = select(ProjectOverviewSelectors.getProject); + currentProject = select(ProjectOverviewSelectors.getProject); + isAnonymous = select(ProjectOverviewSelectors.isProjectAnonymous); private currentProject$ = toObservable(this.currentProject); - protected userPermissions = computed(() => { + + userPermissions = computed(() => { return this.currentProject()?.currentUserPermissions || []; }); + hasViewOnly = computed(() => { + return hasViewOnlyParam(this.router); + }); + get isAdmin(): boolean { return this.userPermissions().includes(UserPermissions.Admin); } @@ -165,33 +173,35 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement return this.userPermissions().includes(UserPermissions.Write); } - protected resourceOverview = computed(() => { + resourceOverview = computed(() => { const project = this.currentProject(); if (project) { - return MapProjectOverview(project); + return MapProjectOverview(project, this.isAnonymous()); } return null; }); - protected isLoading = computed(() => { + isLoading = computed(() => { return this.isProjectLoading() || this.isCollectionProviderLoading() || this.isReviewActionsLoading(); }); - protected currentResource = computed(() => { - if (this.currentProject()) { + currentResource = computed(() => { + const project = this.currentProject(); + if (project) { return { - id: this.currentProject()!.id, - isPublic: this.currentProject()!.isPublic, - storage: this.currentProject()!.storage, - viewOnlyLinksCount: this.currentProject()!.viewOnlyLinksCount, - forksCount: this.currentProject()!.forksCount, + id: project.id, + isPublic: project.isPublic, + storage: project.storage, + viewOnlyLinksCount: project.viewOnlyLinksCount, + forksCount: project.forksCount, resourceType: ResourceType.Project, + isAnonymous: this.isAnonymous(), }; } return null; }); - protected getDoi(): Observable { + getDoi(): Observable { return this.currentProject$.pipe( filter((project) => project != null), map((project) => project?.identifiers?.find((item) => item.category == 'doi')?.value ?? null) @@ -221,7 +231,7 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement } } - protected handleOpenMakeDecisionDialog() { + handleOpenMakeDecisionDialog() { const dialogWidth = this.isMobile() ? '95vw' : '600px'; this.dialogService @@ -242,7 +252,7 @@ export class ProjectOverviewComponent extends DataciteTrackerComponent implement }); } - protected goBack(): void { + goBack(): void { const currentStatus = this.route.snapshot.queryParams['status']; const queryParams = currentStatus ? { status: currentStatus } : {}; diff --git a/src/app/features/project/overview/services/project-overview.service.ts b/src/app/features/project/overview/services/project-overview.service.ts index 3512d1c7c..b1cc7d779 100644 --- a/src/app/features/project/overview/services/project-overview.service.ts +++ b/src/app/features/project/overview/services/project-overview.service.ts @@ -8,7 +8,7 @@ import { ComponentGetResponseJsonApi, ComponentOverview, JsonApiResponse } from import { JsonApiService } from '@osf/shared/services'; import { ProjectOverviewMapper } from '../mappers'; -import { ProjectOverview, ProjectOverviewResponseJsonApi } from '../models'; +import { ProjectOverviewResponseJsonApi, ProjectOverviewWithMeta } from '../models'; import { environment } from 'src/environments/environment'; @@ -18,7 +18,7 @@ import { environment } from 'src/environments/environment'; export class ProjectOverviewService { private readonly jsonApiService = inject(JsonApiService); - getProjectById(projectId: string): Observable { + getProjectById(projectId: string): Observable { const params: Record = { 'embed[]': [ 'bibliographic_contributors', @@ -36,7 +36,12 @@ export class ProjectOverviewService { return this.jsonApiService .get(`${environment.apiUrl}/nodes/${projectId}/`, params) - .pipe(map((response) => ProjectOverviewMapper.fromGetProjectResponse(response.data))); + .pipe( + map((response) => ({ + project: ProjectOverviewMapper.fromGetProjectResponse(response.data), + meta: response.meta, + })) + ); } updateProjectPublicStatus(projectId: string, isPublic: boolean): Observable { diff --git a/src/app/features/project/overview/store/project-overview.model.ts b/src/app/features/project/overview/store/project-overview.model.ts index 9f7241d0c..8be6cd60b 100644 --- a/src/app/features/project/overview/store/project-overview.model.ts +++ b/src/app/features/project/overview/store/project-overview.model.ts @@ -5,6 +5,7 @@ import { ProjectOverview } from '../models'; export interface ProjectOverviewStateModel { project: AsyncStateModel; components: AsyncStateModel; + isAnonymous: boolean; } export const PROJECT_OVERVIEW_DEFAULTS: ProjectOverviewStateModel = { @@ -20,4 +21,5 @@ export const PROJECT_OVERVIEW_DEFAULTS: ProjectOverviewStateModel = { isSubmitting: false, error: null, }, + isAnonymous: false, }; diff --git a/src/app/features/project/overview/store/project-overview.selectors.ts b/src/app/features/project/overview/store/project-overview.selectors.ts index 6378e3ab9..ef5403262 100644 --- a/src/app/features/project/overview/store/project-overview.selectors.ts +++ b/src/app/features/project/overview/store/project-overview.selectors.ts @@ -43,4 +43,9 @@ export class ProjectOverviewSelectors { static getUpdatePublicStatusSubmitting(state: ProjectOverviewStateModel) { return state.project.isSubmitting; } + + @Selector([ProjectOverviewState]) + static isProjectAnonymous(state: ProjectOverviewStateModel) { + return state.isAnonymous; + } } diff --git a/src/app/features/project/overview/store/project-overview.state.ts b/src/app/features/project/overview/store/project-overview.state.ts index 52443f7e3..5dbf1fc37 100644 --- a/src/app/features/project/overview/store/project-overview.state.ts +++ b/src/app/features/project/overview/store/project-overview.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'; import { ResourceType } from '@shared/enums'; import { ProjectOverviewService } from '../services'; @@ -40,16 +41,17 @@ export class ProjectOverviewState { }); return this.projectOverviewService.getProjectById(action.projectId).pipe( - tap((project) => { + tap((response) => { ctx.patchState({ project: { - data: project, + data: response.project, isLoading: false, error: null, }, + isAnonymous: response.meta?.anonymous ?? false, }); }), - catchError((error) => this.handleError(ctx, 'project', error)) + catchError((error) => handleSectionError(ctx, 'project', error)) ); } @@ -84,7 +86,7 @@ export class ProjectOverviewState { }); } }), - catchError((error) => this.handleError(ctx, 'project', error)) + catchError((error) => handleSectionError(ctx, 'project', error)) ); } @@ -135,7 +137,7 @@ export class ProjectOverviewState { }, }); }), - catchError((error) => this.handleError(ctx, 'project', error)) + catchError((error) => handleSectionError(ctx, 'project', error)) ); } @@ -158,7 +160,7 @@ export class ProjectOverviewState { }, }); }), - catchError((error) => this.handleError(ctx, 'project', error)) + catchError((error) => handleSectionError(ctx, 'project', error)) ); } @@ -191,7 +193,7 @@ export class ProjectOverviewState { }, }); }), - catchError((error) => this.handleError(ctx, 'components', error)) + catchError((error) => handleSectionError(ctx, 'components', error)) ); } @@ -215,7 +217,7 @@ export class ProjectOverviewState { }, }); }), - catchError((error) => this.handleError(ctx, 'components', error)) + catchError((error) => handleSectionError(ctx, 'components', error)) ); } @@ -239,23 +241,7 @@ export class ProjectOverviewState { }, }); }), - catchError((error) => this.handleError(ctx, 'components', error)) + catchError((error) => handleSectionError(ctx, 'components', error)) ); } - - private handleError( - ctx: StateContext, - section: keyof ProjectOverviewStateModel, - error: Error - ) { - ctx.patchState({ - [section]: { - ...ctx.getState()[section], - isLoading: false, - isSubmitting: false, - error: error.message, - }, - }); - return throwError(() => error); - } } diff --git a/src/app/features/project/project.routes.ts b/src/app/features/project/project.routes.ts index e5124e04f..91f8a6f26 100644 --- a/src/app/features/project/project.routes.ts +++ b/src/app/features/project/project.routes.ts @@ -2,6 +2,7 @@ import { provideStates } from '@ngxs/store'; import { Routes } from '@angular/router'; +import { viewOnlyGuard } from '@osf/core/guards'; import { ResourceType } from '@osf/shared/enums'; import { CitationsState, @@ -45,6 +46,7 @@ export const projectRoutes: Routes = [ }, { path: 'metadata', + canActivate: [viewOnlyGuard], loadChildren: () => import('../project/metadata/project-metadata.routes').then((mod) => mod.projectMetadataRoutes), providers: [provideStates([ContributorsState, SubjectsState])], @@ -56,16 +58,19 @@ export const projectRoutes: Routes = [ }, { path: 'registrations', + canActivate: [viewOnlyGuard], loadComponent: () => import('../project/registrations/registrations.component').then((mod) => mod.RegistrationsComponent), }, { path: 'settings', + canActivate: [viewOnlyGuard], loadComponent: () => import('../project/settings/settings.component').then((mod) => mod.SettingsComponent), providers: [provideStates([SettingsState, ViewOnlyLinkState])], }, { path: 'contributors', + canActivate: [viewOnlyGuard], loadComponent: () => import('../project/contributors/contributors.component').then((mod) => mod.ContributorsComponent), data: { resourceType: ResourceType.Project }, @@ -92,6 +97,7 @@ export const projectRoutes: Routes = [ }, { path: 'addons', + canActivate: [viewOnlyGuard], loadChildren: () => import('../project/addons/addons.routes').then((mod) => mod.addonsRoutes), }, ], diff --git a/src/app/features/project/settings/settings.component.html b/src/app/features/project/settings/settings.component.html index d9ef31199..5584dfb98 100644 --- a/src/app/features/project/settings/settings.component.html +++ b/src/app/features/project/settings/settings.component.html @@ -17,6 +17,7 @@ > { this.actions.deleteViewOnlyLink(this.projectId(), ResourceType.Project, link.id).subscribe(() => { - this.toastService.showSuccess('myProjects.settings.delete.success'); + this.toastService.showSuccess('myProjects.settings.viewOnlyLinkDeleted'); this.loaderService.hide(); }); }, diff --git a/src/app/features/project/wiki/wiki.component.html b/src/app/features/project/wiki/wiki.component.html index 7c9b1a688..8e2debb7c 100644 --- a/src/app/features/project/wiki/wiki.component.html +++ b/src/app/features/project/wiki/wiki.component.html @@ -5,11 +5,13 @@ [variant]="wikiModes().view ? undefined : 'outlined'" (onClick)="toggleMode(WikiModes.View)" /> - + @if (!hasViewOnly()) { + + } +@if (hasViewOnly()) { +
+ +
+} +
@@ -37,7 +46,7 @@ (selectVersion)="onSelectVersion($event)" > } - @if (wikiModes().edit) { + @if (!hasViewOnly() && wikiModes().edit) { params['id'])) ?? of(undefined)); - protected wikiModes = select(WikiSelectors.getWikiModes); - protected previewContent = select(WikiSelectors.getPreviewContent); - protected versionContent = select(WikiSelectors.getWikiVersionContent); - protected compareVersionContent = select(WikiSelectors.getCompareVersionContent); - protected isWikiListLoading = select(WikiSelectors.getWikiListLoading || WikiSelectors.getComponentsWikiListLoading); - protected wikiList = select(WikiSelectors.getWikiList); - protected componentsWikiList = select(WikiSelectors.getComponentsWikiList); - protected currentWikiId = select(WikiSelectors.getCurrentWikiId); - protected wikiVersions = select(WikiSelectors.getWikiVersions); - protected isWikiVersionSubmitting = select(WikiSelectors.getWikiVersionSubmitting); - protected isWikiVersionLoading = select(WikiSelectors.getWikiVersionsLoading); - protected isCompareVersionLoading = select(WikiSelectors.getCompareVersionsLoading); - - protected actions = createDispatchMap({ + wikiModes = select(WikiSelectors.getWikiModes); + previewContent = select(WikiSelectors.getPreviewContent); + versionContent = select(WikiSelectors.getWikiVersionContent); + compareVersionContent = select(WikiSelectors.getCompareVersionContent); + isWikiListLoading = select(WikiSelectors.getWikiListLoading || WikiSelectors.getComponentsWikiListLoading); + wikiList = select(WikiSelectors.getWikiList); + componentsWikiList = select(WikiSelectors.getComponentsWikiList); + currentWikiId = select(WikiSelectors.getCurrentWikiId); + wikiVersions = select(WikiSelectors.getWikiVersions); + isWikiVersionSubmitting = select(WikiSelectors.getWikiVersionSubmitting); + isWikiVersionLoading = select(WikiSelectors.getWikiVersionsLoading); + isCompareVersionLoading = select(WikiSelectors.getCompareVersionsLoading); + isAnonymous = select(WikiSelectors.isWikiAnonymous); + hasViewOnly = computed(() => { + return hasViewOnlyParam(this.router); + }); + + actions = createDispatchMap({ getWikiModes: GetWikiModes, toggleMode: ToggleMode, getWikiContent: GetWikiContent, @@ -92,7 +99,7 @@ export class WikiComponent { getCompareVersionContent: GetCompareVersionContent, }); - protected wikiIdFromQueryParams = this.route.snapshot.queryParams['wiki']; + wikiIdFromQueryParams = this.route.snapshot.queryParams['wiki']; constructor() { this.actions diff --git a/src/app/features/registries/store/handlers/files.handlers.ts b/src/app/features/registries/store/handlers/files.handlers.ts index bdfa6cee4..825a4b98f 100644 --- a/src/app/features/registries/store/handlers/files.handlers.ts +++ b/src/app/features/registries/store/handlers/files.handlers.ts @@ -20,14 +20,14 @@ export class FilesHandlers { return this.filesService.getFolders(action.folderLink).pipe( tap({ - next: (folders) => + next: (response) => ctx.patchState({ rootFolders: { - data: folders, + data: response.files, isLoading: false, error: null, }, - currentFolder: folders.length > 0 ? folders[0] : null, + currentFolder: response.files.length > 0 ? response.files[0] : null, }), }), catchError((error) => handleSectionError(ctx, 'rootFolders', error)) diff --git a/src/app/features/registry/components/registry-statuses/registry-statuses.component.html b/src/app/features/registry/components/registry-statuses/registry-statuses.component.html index b4a1404b6..ffbf58693 100644 --- a/src/app/features/registry/components/registry-statuses/registry-statuses.component.html +++ b/src/app/features/registry/components/registry-statuses/registry-statuses.component.html @@ -7,20 +7,22 @@

{{ 'registry.overview.statuses.' + registry()?.status + '.short' | translate }}

{{ 'registry.overview.statuses.' + registry()?.status + '.long' | translate }}

- @if (canWithdraw) { - - } - @if (registry()?.status === RegistryStatus.Embargo) { - + @if (!hasViewOnly()) { + @if (canWithdraw) { + + } + @if (registry()?.status === RegistryStatus.Embargo) { + + } }
diff --git a/src/app/features/registry/components/registry-statuses/registry-statuses.component.ts b/src/app/features/registry/components/registry-statuses/registry-statuses.component.ts index 9ffeeddda..e3048d041 100644 --- a/src/app/features/registry/components/registry-statuses/registry-statuses.component.ts +++ b/src/app/features/registry/components/registry-statuses/registry-statuses.component.ts @@ -1,4 +1,4 @@ -import { createDispatchMap, Store } from '@ngxs/store'; +import { createDispatchMap } from '@ngxs/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; @@ -6,13 +6,15 @@ import { Accordion, AccordionContent, AccordionHeader, AccordionPanel } from 'pr import { Button } from 'primeng/button'; import { DialogService } from 'primeng/dynamicdialog'; -import { ChangeDetectionStrategy, Component, HostBinding, inject, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, HostBinding, inject, input } from '@angular/core'; +import { Router } from '@angular/router'; import { WithdrawDialogComponent } from '@osf/features/registry/components'; import { RegistryOverview } from '@osf/features/registry/models'; import { MakePublic } from '@osf/features/registry/store/registry-overview'; import { RegistrationReviewStates, RevisionReviewStates } from '@osf/shared/enums'; import { RegistryStatus } from '@shared/enums'; +import { hasViewOnlyParam } from '@shared/helpers'; import { CustomConfirmationService } from '@shared/services'; @Component({ @@ -24,14 +26,14 @@ import { CustomConfirmationService } from '@shared/services'; }) export class RegistryStatusesComponent { @HostBinding('class') classes = 'flex-1 flex'; - private readonly store = inject(Store); + private readonly router = inject(Router); registry = input.required(); private readonly dialogService = inject(DialogService); private readonly translateService = inject(TranslateService); - protected readonly RegistryStatus = RegistryStatus; - protected readonly RevisionReviewStates = RevisionReviewStates; - protected readonly customConfirmationService = inject(CustomConfirmationService); - protected readonly actions = createDispatchMap({ + readonly RegistryStatus = RegistryStatus; + readonly RevisionReviewStates = RevisionReviewStates; + readonly customConfirmationService = inject(CustomConfirmationService); + readonly actions = createDispatchMap({ makePublic: MakePublic, }); @@ -42,6 +44,10 @@ export class RegistryStatusesComponent { ); } + hasViewOnly = computed(() => { + return hasViewOnlyParam(this.router); + }); + openWithdrawDialog(): void { const registry = this.registry(); if (registry) { diff --git a/src/app/features/registry/models/get-registry-overview-json-api.model.ts b/src/app/features/registry/models/get-registry-overview-json-api.model.ts index 379a8b76f..a9679683c 100644 --- a/src/app/features/registry/models/get-registry-overview-json-api.model.ts +++ b/src/app/features/registry/models/get-registry-overview-json-api.model.ts @@ -1,7 +1,17 @@ import { RegistrationReviewStates, RevisionReviewStates } from '@osf/shared/enums'; -import { ApiData, JsonApiResponse, ProviderDataJsonApi, SchemaResponseDataJsonApi } from '@osf/shared/models'; +import { + ApiData, + JsonApiResponseWithMeta, + MetaAnonymousJsonApi, + ProviderDataJsonApi, + SchemaResponseDataJsonApi, +} from '@osf/shared/models'; -export type GetRegistryOverviewJsonApi = JsonApiResponse; +export type GetRegistryOverviewJsonApi = JsonApiResponseWithMeta< + RegistryOverviewJsonApiData, + MetaAnonymousJsonApi, + null +>; export type RegistryOverviewJsonApiData = ApiData< RegistryOverviewJsonApiAttributes, diff --git a/src/app/features/registry/models/registry-overview.models.ts b/src/app/features/registry/models/registry-overview.models.ts index 7c9b75413..ddad69854 100644 --- a/src/app/features/registry/models/registry-overview.models.ts +++ b/src/app/features/registry/models/registry-overview.models.ts @@ -1,7 +1,7 @@ import { ProjectOverviewContributor } from '@osf/features/project/overview/models'; import { RegistrationQuestions, RegistrySubject } from '@osf/features/registry/models'; import { RegistrationReviewStates, RegistryStatus, RevisionReviewStates } from '@shared/enums'; -import { License, ProviderModel, SchemaResponse } from '@shared/models'; +import { License, MetaAnonymousJsonApi, ProviderModel, SchemaResponse } from '@shared/models'; export interface RegistryOverview { id: string; @@ -68,3 +68,8 @@ export interface RegistryOverview { withdrawalJustification?: string; dateWithdrawn: string | null; } + +export interface RegistryOverviewWithMeta { + registry: RegistryOverview | null; + meta?: MetaAnonymousJsonApi; +} diff --git a/src/app/features/registry/pages/registry-components/registry-components.component.html b/src/app/features/registry/pages/registry-components/registry-components.component.html index 42fd9c06c..381e4ce9f 100644 --- a/src/app/features/registry/pages/registry-components/registry-components.component.html +++ b/src/app/features/registry/pages/registry-components/registry-components.component.html @@ -1,8 +1,11 @@
-
-
+
+ @if (hasViewOnly()) { + + } +
@if (registryComponentsLoading()) {
diff --git a/src/app/features/registry/pages/registry-components/registry-components.component.scss b/src/app/features/registry/pages/registry-components/registry-components.component.scss index e69de29bb..da0c027b5 100644 --- a/src/app/features/registry/pages/registry-components/registry-components.component.scss +++ b/src/app/features/registry/pages/registry-components/registry-components.component.scss @@ -0,0 +1,5 @@ +:host { + display: flex; + flex-direction: column; + flex: 1; +} diff --git a/src/app/features/registry/pages/registry-components/registry-components.component.ts b/src/app/features/registry/pages/registry-components/registry-components.component.ts index 2edd5e28d..0dc699230 100644 --- a/src/app/features/registry/pages/registry-components/registry-components.component.ts +++ b/src/app/features/registry/pages/registry-components/registry-components.component.ts @@ -2,20 +2,28 @@ import { createDispatchMap, select } from '@ngxs/store'; import { TranslatePipe } from '@ngx-translate/core'; -import { ChangeDetectionStrategy, Component, effect, inject, OnInit, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, effect, inject, OnInit, signal } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { RegistrationLinksCardComponent } from '@osf/features/registry/components'; import { RegistryComponentModel } from '@osf/features/registry/models'; import { GetRegistryById, RegistryOverviewSelectors } from '@osf/features/registry/store/registry-overview'; import { LoadingSpinnerComponent, SubHeaderComponent } from '@shared/components'; +import { ViewOnlyLinkMessageComponent } from '@shared/components/view-only-link-message/view-only-link-message.component'; +import { hasViewOnlyParam } from '@shared/helpers'; import { GetRegistryComponents, RegistryComponentsSelectors } from '../../store/registry-components'; import { GetBibliographicContributorsForRegistration, RegistryLinksSelectors } from '../../store/registry-links'; @Component({ selector: 'osf-registry-components', - imports: [SubHeaderComponent, TranslatePipe, LoadingSpinnerComponent, RegistrationLinksCardComponent], + imports: [ + SubHeaderComponent, + TranslatePipe, + LoadingSpinnerComponent, + RegistrationLinksCardComponent, + ViewOnlyLinkMessageComponent, + ], templateUrl: './registry-components.component.html', styleUrl: './registry-components.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, @@ -26,25 +34,27 @@ export class RegistryComponentsComponent implements OnInit { private registryId = signal(''); - protected actions = createDispatchMap({ + actions = createDispatchMap({ getRegistryComponents: GetRegistryComponents, getBibliographicContributorsForRegistration: GetBibliographicContributorsForRegistration, getRegistryById: GetRegistryById, }); + hasViewOnly = computed(() => { + return hasViewOnlyParam(this.router); + }); + components = signal([]); - protected registryComponents = select(RegistryComponentsSelectors.getRegistryComponents); - protected registryComponentsLoading = select(RegistryComponentsSelectors.getRegistryComponentsLoading); + registryComponents = select(RegistryComponentsSelectors.getRegistryComponents); + registryComponentsLoading = select(RegistryComponentsSelectors.getRegistryComponentsLoading); - protected bibliographicContributorsForRegistration = select( - RegistryLinksSelectors.getBibliographicContributorsForRegistration - ); - protected bibliographicContributorsForRegistrationId = select( + bibliographicContributorsForRegistration = select(RegistryLinksSelectors.getBibliographicContributorsForRegistration); + bibliographicContributorsForRegistrationId = select( RegistryLinksSelectors.getBibliographicContributorsForRegistrationId ); - protected registry = select(RegistryOverviewSelectors.getRegistry); + registry = select(RegistryOverviewSelectors.getRegistry); constructor() { effect(() => { diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.html b/src/app/features/registry/pages/registry-overview/registry-overview.component.html index 90e3c0061..74ba3f8b0 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.html +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.html @@ -27,7 +27,7 @@
} @else {
- @if (!schemaResponse()?.isOriginalResponse && !isInitialState) { + @if (!schemaResponse()?.isOriginalResponse && !isInitialState && !hasViewOnly()) {
@@ -45,6 +45,9 @@

} + @if (hasViewOnly()) { + + }
-
- - - -
+ @if (!isAnonymous()) { +
+ + + +
+ }
@for (page of schemaBlocks(); track page.id) { @@ -116,7 +121,7 @@

{{ section.title }}

diff --git a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts index 10adb104c..ef6f6d95a 100644 --- a/src/app/features/registry/pages/registry-overview/registry-overview.component.ts +++ b/src/app/features/registry/pages/registry-overview/registry-overview.component.ts @@ -22,11 +22,12 @@ import { SubHeaderComponent, } from '@osf/shared/components'; import { RegistrationReviewStates, ResourceType, RevisionReviewStates, UserPermissions } from '@osf/shared/enums'; -import { toCamelCase } from '@osf/shared/helpers'; +import { hasViewOnlyParam, toCamelCase } from '@osf/shared/helpers'; import { MapRegistryOverview } from '@osf/shared/mappers'; import { SchemaResponse, ToolbarResource } from '@osf/shared/models'; import { ToastService } from '@osf/shared/services'; import { GetBookmarksCollectionId } from '@osf/shared/stores'; +import { ViewOnlyLinkMessageComponent } from '@shared/components/view-only-link-message/view-only-link-message.component'; import { ArchivingMessageComponent, RegistryRevisionsComponent, RegistryStatusesComponent } from '../../components'; import { RegistryMakeDecisionComponent } from '../../components/registry-make-decision/registry-make-decision.component'; @@ -56,6 +57,7 @@ import { RegistrationBlocksDataComponent, Message, DatePipe, + ViewOnlyLinkMessageComponent, ], templateUrl: './registry-overview.component.html', styleUrl: './registry-overview.component.scss', @@ -71,20 +73,21 @@ export class RegistryOverviewComponent { private readonly dialogService = inject(DialogService); private readonly translateService = inject(TranslateService); - protected readonly registry = select(RegistryOverviewSelectors.getRegistry); - protected readonly isRegistryLoading = select(RegistryOverviewSelectors.isRegistryLoading); - protected readonly subjects = select(RegistryOverviewSelectors.getSubjects); - protected readonly isSubjectsLoading = select(RegistryOverviewSelectors.isSubjectsLoading); - protected readonly institutions = select(RegistryOverviewSelectors.getInstitutions); - protected readonly isInstitutionsLoading = select(RegistryOverviewSelectors.isInstitutionsLoading); - protected readonly schemaBlocks = select(RegistryOverviewSelectors.getSchemaBlocks); - protected readonly isSchemaBlocksLoading = select(RegistryOverviewSelectors.isSchemaBlocksLoading); - protected readonly areReviewActionsLoading = select(RegistryOverviewSelectors.areReviewActionsLoading); - protected readonly currentRevision = select(RegistriesSelectors.getSchemaResponse); - protected readonly isSchemaResponseLoading = select(RegistriesSelectors.getSchemaResponseLoading); - protected revisionInProgress: SchemaResponse | undefined; + readonly registry = select(RegistryOverviewSelectors.getRegistry); + readonly isRegistryLoading = select(RegistryOverviewSelectors.isRegistryLoading); + readonly isAnonymous = select(RegistryOverviewSelectors.isRegistryAnonymous); + readonly subjects = select(RegistryOverviewSelectors.getSubjects); + readonly isSubjectsLoading = select(RegistryOverviewSelectors.isSubjectsLoading); + readonly institutions = select(RegistryOverviewSelectors.getInstitutions); + readonly isInstitutionsLoading = select(RegistryOverviewSelectors.isInstitutionsLoading); + readonly schemaBlocks = select(RegistryOverviewSelectors.getSchemaBlocks); + readonly isSchemaBlocksLoading = select(RegistryOverviewSelectors.isSchemaBlocksLoading); + readonly areReviewActionsLoading = select(RegistryOverviewSelectors.areReviewActionsLoading); + readonly currentRevision = select(RegistriesSelectors.getSchemaResponse); + readonly isSchemaResponseLoading = select(RegistriesSelectors.getSchemaResponseLoading); + revisionInProgress: SchemaResponse | undefined; - protected readonly schemaResponse = computed(() => { + readonly schemaResponse = computed(() => { const registry = this.registry(); const index = this.selectedRevisionIndex(); this.revisionInProgress = registry?.schemaResponses.find( @@ -100,7 +103,7 @@ export class RegistryOverviewComponent { return null; }); - protected readonly updatedFields = computed(() => { + readonly updatedFields = computed(() => { const schemaResponse = this.schemaResponse(); if (schemaResponse) { return schemaResponse.updatedResponseKeys || []; @@ -108,19 +111,19 @@ export class RegistryOverviewComponent { return []; }); - protected readonly resourceOverview = computed(() => { + readonly resourceOverview = computed(() => { const registry = this.registry(); const subjects = this.subjects(); const institutions = this.institutions(); if (registry && subjects && institutions) { - return MapRegistryOverview(registry, subjects, institutions); + return MapRegistryOverview(registry, subjects, institutions, this.isAnonymous()); } return null; }); - protected readonly selectedRevisionIndex = signal(0); + readonly selectedRevisionIndex = signal(0); - protected toolbarResource = computed(() => { + toolbarResource = computed(() => { if (this.registry()) { return { id: this.registry()!.id, @@ -129,6 +132,7 @@ export class RegistryOverviewComponent { viewOnlyLinksCount: 0, forksCount: this.registry()!.forksCount, resourceType: ResourceType.Registration, + isAnonymous: this.isAnonymous(), } as ToolbarResource; } return null; @@ -148,10 +152,14 @@ export class RegistryOverviewComponent { revisionId: string | null = null; isModeration = false; - protected userPermissions = computed(() => { + userPermissions = computed(() => { return this.registry()?.currentUserPermissions || []; }); + hasViewOnly = computed(() => { + return hasViewOnlyParam(this.router); + }); + get isAdmin(): boolean { return this.userPermissions().includes(UserPermissions.Admin); } @@ -246,7 +254,7 @@ export class RegistryOverviewComponent { this.router.navigate([`/registries/revisions/${revisionId}/review`]); } - protected handleOpenMakeDecisionDialog() { + handleOpenMakeDecisionDialog() { const dialogWidth = '600px'; this.actions .getRegistryReviewActions(this.registry()?.id || '') diff --git a/src/app/features/registry/pages/registry-wiki/registry-wiki.component.html b/src/app/features/registry/pages/registry-wiki/registry-wiki.component.html index 58b7cb552..01bedd68c 100644 --- a/src/app/features/registry/pages/registry-wiki/registry-wiki.component.html +++ b/src/app/features/registry/pages/registry-wiki/registry-wiki.component.html @@ -13,9 +13,15 @@ +@if (hasViewOnly()) { +
+ +
+} +
{ + return hasViewOnlyParam(this.router); + }); readonly resourceId = this.route.parent?.snapshot.params['id']; - protected actions = createDispatchMap({ + actions = createDispatchMap({ toggleMode: ToggleMode, getWikiContent: GetWikiContent, getWikiList: GetWikiList, @@ -68,7 +75,7 @@ export class RegistryWikiComponent { getCompareVersionContent: GetCompareVersionContent, }); - protected wikiIdFromQueryParams = this.route.snapshot.queryParams['wiki']; + wikiIdFromQueryParams = this.route.snapshot.queryParams['wiki']; constructor() { this.actions diff --git a/src/app/features/registry/registry.component.ts b/src/app/features/registry/registry.component.ts index ac8910fbf..d8005d983 100644 --- a/src/app/features/registry/registry.component.ts +++ b/src/app/features/registry/registry.component.ts @@ -25,7 +25,7 @@ export class RegistryComponent { private readonly metaTags = inject(MetaTagsService); private readonly datePipe = inject(DatePipe); - protected readonly registry = select(RegistryOverviewSelectors.getRegistry); + readonly registry = select(RegistryOverviewSelectors.getRegistry); constructor() { effect(() => { @@ -51,10 +51,11 @@ export class RegistryComponent { keywords: this.registry()?.tags, siteName: 'OSF', license: this.registry()?.license?.name, - contributors: this.registry()?.contributors.map((contributor) => ({ - givenName: contributor.givenName, - familyName: contributor.familyName, - })), + contributors: + this.registry()?.contributors?.map((contributor) => ({ + givenName: contributor.givenName, + familyName: contributor.familyName, + })) ?? [], }, 'registries' ); diff --git a/src/app/features/registry/registry.routes.ts b/src/app/features/registry/registry.routes.ts index cbbdb84e1..0f442bc6f 100644 --- a/src/app/features/registry/registry.routes.ts +++ b/src/app/features/registry/registry.routes.ts @@ -2,6 +2,7 @@ import { provideStates } from '@ngxs/store'; import { Routes } from '@angular/router'; +import { viewOnlyGuard } from '@osf/core/guards'; import { ResourceType } from '@osf/shared/enums'; import { LicensesService } from '@osf/shared/services'; import { @@ -50,12 +51,14 @@ export const registryRoutes: Routes = [ }, { path: 'metadata', + canActivate: [viewOnlyGuard], loadComponent: () => import('./pages/registry-metadata/registry-metadata.component').then((c) => c.RegistryMetadataComponent), providers: [provideStates([RegistryMetadataState, SubjectsState])], }, { path: 'metadata/add', + canActivate: [viewOnlyGuard], loadComponent: () => import('./pages/registry-metadata-add/registry-metadata-add.component').then( (c) => c.RegistryMetadataAddComponent @@ -64,18 +67,21 @@ export const registryRoutes: Routes = [ }, { path: 'metadata/:recordId', + canActivate: [viewOnlyGuard], loadComponent: () => import('./pages/registry-metadata/registry-metadata.component').then((c) => c.RegistryMetadataComponent), providers: [provideStates([RegistryMetadataState])], }, { path: 'links', + canActivate: [viewOnlyGuard], loadComponent: () => import('./pages/registry-links/registry-links.component').then((c) => c.RegistryLinksComponent), providers: [provideStates([RegistryLinksState])], }, { path: 'contributors', + canActivate: [viewOnlyGuard], loadComponent: () => import('../project/contributors/contributors.component').then((mod) => mod.ContributorsComponent), data: { resourceType: ResourceType.Registration }, @@ -103,6 +109,7 @@ export const registryRoutes: Routes = [ }, { path: 'components', + canActivate: [viewOnlyGuard], loadComponent: () => import('./pages/registry-components/registry-components.component').then( (c) => c.RegistryComponentsComponent @@ -111,6 +118,7 @@ export const registryRoutes: Routes = [ }, { path: 'resources', + canActivate: [viewOnlyGuard], loadComponent: () => import('./pages/registry-resources/registry-resources.component').then( (mod) => mod.RegistryResourcesComponent diff --git a/src/app/features/registry/services/registry-overview.service.ts b/src/app/features/registry/services/registry-overview.service.ts index 933c04f65..8465e7980 100644 --- a/src/app/features/registry/services/registry-overview.service.ts +++ b/src/app/features/registry/services/registry-overview.service.ts @@ -10,6 +10,7 @@ import { GetResourceSubjectsJsonApi, RegistryOverview, RegistryOverviewJsonApiData, + RegistryOverviewWithMeta, RegistrySubject, } from '@osf/features/registry/models'; import { InstitutionsMapper, ReviewActionsMapper } from '@osf/shared/mappers'; @@ -26,7 +27,7 @@ import { environment } from 'src/environments/environment'; export class RegistryOverviewService { private jsonApiService = inject(JsonApiService); - getRegistrationById(id: string): Observable { + getRegistrationById(id: string): Observable { const params = { related_counts: 'forks,comments,linked_nodes,linked_registrations,children,wikis', 'embed[]': [ @@ -43,7 +44,7 @@ export class RegistryOverviewService { return this.jsonApiService .get(`${environment.apiUrl}/registrations/${id}/`, params) - .pipe(map((response) => MapRegistryOverview(response.data))); + .pipe(map((response) => ({ registry: MapRegistryOverview(response.data), meta: response.meta }))); } getSubjects(registryId: string): Observable { @@ -73,8 +74,16 @@ export class RegistryOverviewService { page: 1, }; + let fullUrl: string; + if (schemaLink.includes('?')) { + const [baseUrl, queryString] = schemaLink.split('?'); + fullUrl = `${baseUrl}schema_blocks/?${queryString}`; + } else { + fullUrl = `${schemaLink}schema_blocks/`; + } + return this.jsonApiService - .get(`${schemaLink}schema_blocks/`, params) + .get(fullUrl, params) .pipe(map((response) => PageSchemaMapper.fromSchemaBlocksResponse(response))); } diff --git a/src/app/features/registry/store/registry-files/registry-files.state.ts b/src/app/features/registry/store/registry-files/registry-files.state.ts index 762f44518..739cc7d8b 100644 --- a/src/app/features/registry/store/registry-files/registry-files.state.ts +++ b/src/app/features/registry/store/registry-files/registry-files.state.ts @@ -36,10 +36,10 @@ export class RegistryFilesState { return this.filesService.getFiles(action.filesLink, state.search, state.sort).pipe( tap({ - next: (files) => { + next: (response) => { ctx.patchState({ files: { - data: files, + data: response.files, isLoading: false, error: null, }, diff --git a/src/app/features/registry/store/registry-overview/registry-overview.model.ts b/src/app/features/registry/store/registry-overview/registry-overview.model.ts index 64b23328e..f242d3f17 100644 --- a/src/app/features/registry/store/registry-overview/registry-overview.model.ts +++ b/src/app/features/registry/store/registry-overview/registry-overview.model.ts @@ -9,6 +9,7 @@ export interface RegistryOverviewStateModel { institutions: AsyncStateModel; schemaBlocks: AsyncStateModel; moderationActions: AsyncStateModel; + isAnonymous: boolean; } export const REGISTRY_OVERVIEW_DEFAULTS: RegistryOverviewStateModel = { @@ -38,4 +39,5 @@ export const REGISTRY_OVERVIEW_DEFAULTS: RegistryOverviewStateModel = { isSubmitting: false, error: null, }, + isAnonymous: false, }; diff --git a/src/app/features/registry/store/registry-overview/registry-overview.selectors.ts b/src/app/features/registry/store/registry-overview/registry-overview.selectors.ts index 05c6b4bc7..be11689a5 100644 --- a/src/app/features/registry/store/registry-overview/registry-overview.selectors.ts +++ b/src/app/features/registry/store/registry-overview/registry-overview.selectors.ts @@ -18,6 +18,11 @@ export class RegistryOverviewSelectors { return state.registry.isLoading; } + @Selector([RegistryOverviewState]) + static isRegistryAnonymous(state: RegistryOverviewStateModel): boolean { + return state.isAnonymous; + } + @Selector([RegistryOverviewState]) static getSubjects(state: RegistryOverviewStateModel): RegistrySubject[] | null { return state.subjects.data; diff --git a/src/app/features/registry/store/registry-overview/registry-overview.state.ts b/src/app/features/registry/store/registry-overview/registry-overview.state.ts index 8fc118972..de1507a47 100644 --- a/src/app/features/registry/store/registry-overview/registry-overview.state.ts +++ b/src/app/features/registry/store/registry-overview/registry-overview.state.ts @@ -45,7 +45,8 @@ export class RegistryOverviewState { return this.registryOverviewService.getRegistrationById(action.id).pipe( tap({ - next: (registryOverview) => { + next: (response) => { + const registryOverview = response.registry; if (registryOverview?.currentUserIsModerator) { ctx.dispatch(new SetUserAsModerator()); } @@ -58,6 +59,7 @@ export class RegistryOverviewState { isLoading: false, error: null, }, + isAnonymous: response.meta?.anonymous ?? false, }); if (registryOverview?.registrationSchemaLink && registryOverview?.questions && !action.isComponentPage) { ctx.dispatch(new GetSchemaBlocks(registryOverview.registrationSchemaLink, registryOverview.questions)); diff --git a/src/app/shared/components/file-menu/file-menu.component.html b/src/app/shared/components/file-menu/file-menu.component.html index 39fcbb9a1..20be5df76 100644 --- a/src/app/shared/components/file-menu/file-menu.component.html +++ b/src/app/shared/components/file-menu/file-menu.component.html @@ -9,7 +9,7 @@ (click)="menu.toggle($event)" > - + diff --git a/src/app/shared/components/file-menu/file-menu.component.ts b/src/app/shared/components/file-menu/file-menu.component.ts index 57cf1da05..c49ef1b2c 100644 --- a/src/app/shared/components/file-menu/file-menu.component.ts +++ b/src/app/shared/components/file-menu/file-menu.component.ts @@ -4,10 +4,12 @@ import { MenuItem } from 'primeng/api'; import { Button } from 'primeng/button'; import { TieredMenu } from 'primeng/tieredmenu'; -import { Component, output } from '@angular/core'; +import { Component, computed, inject, input, output } from '@angular/core'; +import { Router } from '@angular/router'; import { FileMenuType } from '@osf/shared/enums'; import { FileMenuAction, FileMenuData } from '@osf/shared/models'; +import { hasViewOnlyParam } from '@shared/helpers'; @Component({ selector: 'osf-file-menu', @@ -16,29 +18,40 @@ import { FileMenuAction, FileMenuData } from '@osf/shared/models'; styleUrl: './file-menu.component.scss', }) export class FileMenuComponent { + private router = inject(Router); action = output(); + isFolder = input(false); - menuItems: MenuItem[] = [ + hasViewOnly = computed(() => { + return hasViewOnlyParam(this.router); + }); + + private readonly allMenuItems: MenuItem[] = [ { + id: FileMenuType.Download, label: 'common.buttons.download', icon: 'fas fa-download', command: () => this.emitAction(FileMenuType.Download), }, { + id: FileMenuType.Share, label: 'common.buttons.share', icon: 'fas fa-share', items: [ { + id: `${FileMenuType.Share}-email`, label: 'files.detail.actions.share.email', icon: 'fas fa-envelope', command: () => this.emitAction(FileMenuType.Share, { type: 'email' }), }, { + id: `${FileMenuType.Share}-twitter`, label: 'files.detail.actions.share.x', icon: 'fab fa-square-x-twitter', command: () => this.emitAction(FileMenuType.Share, { type: 'twitter' }), }, { + id: `${FileMenuType.Share}-facebook`, label: 'files.detail.actions.share.facebook', icon: 'fab fa-facebook', command: () => this.emitAction(FileMenuType.Share, { type: 'facebook' }), @@ -46,15 +59,18 @@ export class FileMenuComponent { ], }, { + id: FileMenuType.Embed, label: 'common.buttons.embed', icon: 'fas fa-code', items: [ { + id: `${FileMenuType.Embed}-dynamic`, label: 'files.detail.actions.copyDynamicIframe', icon: 'fas fa-file-code', command: () => this.emitAction(FileMenuType.Embed, { type: 'dynamic' }), }, { + id: `${FileMenuType.Embed}-static`, label: 'files.detail.actions.copyStaticIframe', icon: 'fas fa-file-code', command: () => this.emitAction(FileMenuType.Embed, { type: 'static' }), @@ -62,27 +78,54 @@ export class FileMenuComponent { ], }, { + id: FileMenuType.Rename, label: 'common.buttons.rename', icon: 'fas fa-edit', command: () => this.emitAction(FileMenuType.Rename), }, { + id: FileMenuType.Move, label: 'common.buttons.move', icon: 'fas fa-arrows-alt', command: () => this.emitAction(FileMenuType.Move), }, { + id: FileMenuType.Copy, label: 'common.buttons.copy', icon: 'fas fa-copy', command: () => this.emitAction(FileMenuType.Copy), }, { + id: FileMenuType.Delete, label: 'common.buttons.delete', icon: 'fas fa-trash', command: () => this.emitAction(FileMenuType.Delete), }, ]; + menuItems = computed(() => { + if (this.hasViewOnly()) { + const allowedActionsForFiles = [FileMenuType.Download, FileMenuType.Embed, FileMenuType.Share, FileMenuType.Copy]; + const allowedActionsForFolders = [FileMenuType.Download, FileMenuType.Copy]; + + const allowedActions = this.isFolder() ? allowedActionsForFolders : allowedActionsForFiles; + + return this.allMenuItems.filter((item) => { + if (item.command) { + return allowedActions.includes(item.id as FileMenuType); + } + + if (item.items) { + return allowedActions.includes(item.id as FileMenuType); + } + + return false; + }); + } + + return this.allMenuItems; + }); + private emitAction(value: FileMenuType, data?: FileMenuData): void { this.action.emit({ value, data } as FileMenuAction); } diff --git a/src/app/shared/components/files-tree/files-tree.component.html b/src/app/shared/components/files-tree/files-tree.component.html index 75189dbcd..a3b2d97e9 100644 --- a/src/app/shared/components/files-tree/files-tree.component.html +++ b/src/app/shared/components/files-tree/files-tree.component.html @@ -1,5 +1,5 @@
- @if (!viewOnly()) { + @if (!viewOnly() && !hasViewOnly) {
- + +
} @else if (viewOnly() && viewOnlyDownloadable()) {
@@ -93,7 +98,7 @@ @if (!files().length) {
- @if (viewOnly()) { + @if (viewOnly() || hasViewOnly()) {

{{ 'files.emptyState' | translate }}

} @else {
diff --git a/src/app/shared/components/files-tree/files-tree.component.ts b/src/app/shared/components/files-tree/files-tree.component.ts index 35b28b39e..743c40d0c 100644 --- a/src/app/shared/components/files-tree/files-tree.component.ts +++ b/src/app/shared/components/files-tree/files-tree.component.ts @@ -30,6 +30,7 @@ import { embedDynamicJs, embedStaticHtml } from '@osf/features/files/constants'; import { FileMenuType } from '@osf/shared/enums'; import { FileMenuComponent, LoadingSpinnerComponent } from '@shared/components'; import { StopPropagationDirective } from '@shared/directives'; +import { hasViewOnlyParam } from '@shared/helpers'; import { FileMenuAction, FilesTreeActions, OsfFile } from '@shared/models'; import { FileSizePipe } from '@shared/pipes'; import { CustomConfirmationService, FilesService, ToastService } from '@shared/services'; @@ -72,14 +73,17 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { viewOnlyDownloadable = input(false); provider = input(); isDragOver = signal(false); + hasViewOnly = computed(() => { + return hasViewOnlyParam(this.router); + }); entryFileClicked = output(); folderIsOpening = output(); uploadFileConfirmed = output(); - protected readonly FileMenuType = FileMenuType; + readonly FileMenuType = FileMenuType; - protected readonly nodes = computed(() => { + readonly nodes = computed(() => { if (this.currentFolder()?.relationships?.parentFolderLink) { return [ { @@ -94,20 +98,27 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { }); ngAfterViewInit(): void { - this.dropZoneContainerRef()!.nativeElement.addEventListener('dragenter', this.dragEnterHandler); + if (!this.viewOnly()) { + this.dropZoneContainerRef()!.nativeElement.addEventListener('dragenter', this.dragEnterHandler); + } } ngOnDestroy(): void { - this.dropZoneContainerRef()!.nativeElement.removeEventListener('dragenter', this.dragEnterHandler); + if (!this.viewOnly()) { + this.dropZoneContainerRef()!.nativeElement.removeEventListener('dragenter', this.dragEnterHandler); + } } private dragEnterHandler = (event: DragEvent) => { - if (event.dataTransfer?.types?.includes('Files')) { + if (event.dataTransfer?.types?.includes('Files') && !this.viewOnly()) { this.isDragOver.set(true); } }; onDragOver(event: DragEvent) { + if (this.viewOnly()) { + return; + } event.preventDefault(); event.stopPropagation(); event.dataTransfer!.dropEffect = 'copy'; @@ -115,6 +126,9 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { } onDragLeave(event: Event) { + if (this.viewOnly()) { + return; + } event.preventDefault(); event.stopPropagation(); this.isDragOver.set(false); @@ -124,6 +138,11 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { event.preventDefault(); event.stopPropagation(); this.isDragOver.set(false); + + if (this.viewOnly()) { + return; + } + const files = event.dataTransfer?.files; if (files && files.length > 0) { @@ -212,10 +231,10 @@ export class FilesTreeComponent implements OnDestroy, AfterViewInit { this.confirmRename(file); break; case FileMenuType.Move: - this.moveFile(file, 'move'); + this.moveFile(file, FileMenuType.Move); break; case FileMenuType.Copy: - this.moveFile(file, 'copy'); + this.moveFile(file, FileMenuType.Copy); break; } } diff --git a/src/app/shared/components/index.ts b/src/app/shared/components/index.ts index 347868e5a..e2eee18b5 100644 --- a/src/app/shared/components/index.ts +++ b/src/app/shared/components/index.ts @@ -6,6 +6,7 @@ export { ConfirmEmailComponent } from './confirm-email/confirm-email.component'; export { CopyButtonComponent } from './copy-button/copy-button.component'; export { CustomPaginatorComponent } from './custom-paginator/custom-paginator.component'; export { DataResourcesComponent } from './data-resources/data-resources.component'; +export { DataciteTrackerComponent } from './datacite-tracker/datacite-tracker.component'; export { EducationHistoryComponent } from './education-history/education-history.component'; export { EducationHistoryDialogComponent } from './education-history-dialog/education-history-dialog.component'; export { EmploymentHistoryComponent } from './employment-history/employment-history.component'; @@ -47,4 +48,5 @@ export { TagsInputComponent } from './tags-input/tags-input.component'; export { TextInputComponent } from './text-input/text-input.component'; export { ToastComponent } from './toast/toast.component'; export { TruncatedTextComponent } from './truncated-text/truncated-text.component'; +export { ViewOnlyLinkMessageComponent } from './view-only-link-message/view-only-link-message.component'; export { ViewOnlyTableComponent } from './view-only-table/view-only-table.component'; diff --git a/src/app/shared/components/resource-citations/resource-citations.component.html b/src/app/shared/components/resource-citations/resource-citations.component.html index 5aafdde8e..41f790c93 100644 --- a/src/app/shared/components/resource-citations/resource-citations.component.html +++ b/src/app/shared/components/resource-citations/resource-citations.component.html @@ -69,13 +69,14 @@

{{ citation.title }}

{{ styledCitation()?.citation }}

} - - + @if (!hasViewOnly) { + + } } } } diff --git a/src/app/shared/components/resource-citations/resource-citations.component.ts b/src/app/shared/components/resource-citations/resource-citations.component.ts index 718f3c5ea..83c37a89c 100644 --- a/src/app/shared/components/resource-citations/resource-citations.component.ts +++ b/src/app/shared/components/resource-citations/resource-citations.component.ts @@ -24,7 +24,9 @@ import { signal, } from '@angular/core'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { hasViewOnlyParam } from '@shared/helpers'; import { CitationStyle, CustomOption, ResourceOverview } from '@shared/models'; import { ToastService } from '@shared/services'; import { @@ -57,6 +59,7 @@ import { }) export class ResourceCitationsComponent { private readonly destroyRef = inject(DestroyRef); + private readonly router = inject(Router); private readonly translateService = inject(TranslateService); isCollectionsRoute = input(false); currentResource = input.required(); @@ -64,24 +67,26 @@ export class ResourceCitationsComponent { private readonly toastService = inject(ToastService); private readonly destroy$ = new Subject(); private readonly filterSubject = new Subject(); - protected customCitation = output(); - protected defaultCitations = select(CitationsSelectors.getDefaultCitations); - protected isCitationsLoading = select(CitationsSelectors.getDefaultCitationsLoading); - protected citationStyles = select(CitationsSelectors.getCitationStyles); - protected isCitationStylesLoading = select(CitationsSelectors.getCitationStylesLoading); - protected isCustomCitationSubmitting = select(CitationsSelectors.getCustomCitationSubmitting); - protected styledCitation = select(CitationsSelectors.getStyledCitation); - protected citationStylesOptions = signal[]>([]); - protected isEditMode = signal(false); - protected filterMessage = computed(() => { + customCitation = output(); + defaultCitations = select(CitationsSelectors.getDefaultCitations); + isCitationsLoading = select(CitationsSelectors.getDefaultCitationsLoading); + citationStyles = select(CitationsSelectors.getCitationStyles); + isCitationStylesLoading = select(CitationsSelectors.getCitationStylesLoading); + isCustomCitationSubmitting = select(CitationsSelectors.getCustomCitationSubmitting); + styledCitation = select(CitationsSelectors.getStyledCitation); + citationStylesOptions = signal[]>([]); + isEditMode = signal(false); + filterMessage = computed(() => { const isLoading = this.isCitationStylesLoading(); return isLoading ? this.translateService.instant('project.overview.metadata.citationLoadingPlaceholder') : this.translateService.instant('project.overview.metadata.noCitationStylesFound'); }); - protected customCitationInput = new FormControl(''); - - protected actions = createDispatchMap({ + customCitationInput = new FormControl(''); + readonly hasViewOnly = computed(() => { + return hasViewOnlyParam(this.router); + }); + actions = createDispatchMap({ getDefaultCitations: GetDefaultCitations, getCitationStyles: GetCitationStyles, getStyledCitation: GetStyledCitation, @@ -96,7 +101,7 @@ export class ResourceCitationsComponent { this.setupDestroyEffect(); } - protected setupDefaultCitationsEffect(): void { + setupDefaultCitationsEffect(): void { effect(() => { const resource = this.currentResource(); @@ -107,12 +112,12 @@ export class ResourceCitationsComponent { }); } - protected handleCitationStyleFilterSearch(event: SelectFilterEvent) { + handleCitationStyleFilterSearch(event: SelectFilterEvent) { event.originalEvent.preventDefault(); this.filterSubject.next(event.filter); } - protected handleGetStyledCitation(event: SelectChangeEvent) { + handleGetStyledCitation(event: SelectChangeEvent) { const resource = this.currentResource(); if (resource) { @@ -120,7 +125,7 @@ export class ResourceCitationsComponent { } } - protected handleUpdateCustomCitation(): void { + handleUpdateCustomCitation(): void { const resource = this.currentResource(); const customCitationText = this.customCitationInput.value?.trim(); @@ -142,7 +147,7 @@ export class ResourceCitationsComponent { } } - protected handleDeleteCustomCitation(): void { + handleDeleteCustomCitation(): void { const resource = this.currentResource(); if (resource) { @@ -163,14 +168,14 @@ export class ResourceCitationsComponent { } } - protected toggleEditMode(): void { + toggleEditMode(): void { if (this.styledCitation()) { this.actions.clearStyledCitation(); } this.isEditMode.set(!this.isEditMode()); } - protected copyCitation(): void { + copyCitation(): void { const resource = this.currentResource(); if (resource?.customCitation) { diff --git a/src/app/shared/components/resource-metadata/resource-metadata.component.html b/src/app/shared/components/resource-metadata/resource-metadata.component.html index 113691867..68131c9cd 100644 --- a/src/app/shared/components/resource-metadata/resource-metadata.component.html +++ b/src/app/shared/components/resource-metadata/resource-metadata.component.html @@ -18,11 +18,15 @@

{{ 'project.overview.metadata.title' | translate }}

{{ 'project.overview.metadata.contributors' | translate }}

- @for (contributor of resource.contributors; track contributor.id) { -
- {{ contributor.fullName }} - {{ $last ? '' : ',' }} -
+ @if (!resource.isAnonymous) { + @for (contributor of resource.contributors; track contributor.id) { +
+ {{ contributor.fullName }} + {{ $last ? '' : ',' }} +
+ } + } @else { +

{{ 'project.overview.metadata.anonymousContributors' | translate }}

}
@@ -98,33 +102,37 @@

{{ 'project.overview.metadata.license' | translate }}

{{ resource.license?.name ?? ('project.overview.metadata.noLicense' | translate) }}
-
+ } - + @if (!resource.isAnonymous) { + + } @if (resource.type === resourceTypes.Projects) { @@ -157,11 +165,12 @@

{{ 'project.overview.metadata.tags' | translate }}

{{ 'project.overview.metadata.noTags' | translate }}

}
- - + @if (!resource.isAnonymous) { + + }
} diff --git a/src/app/shared/components/view-only-link-message/view-only-link-message.component.html b/src/app/shared/components/view-only-link-message/view-only-link-message.component.html new file mode 100644 index 000000000..3d786fb96 --- /dev/null +++ b/src/app/shared/components/view-only-link-message/view-only-link-message.component.html @@ -0,0 +1,10 @@ + + +
+

+ {{ 'common.hint.viewOnlyLinksBanner' | translate }} +

+ +
+
+
diff --git a/src/app/shared/components/view-only-link-message/view-only-link-message.component.scss b/src/app/shared/components/view-only-link-message/view-only-link-message.component.scss new file mode 100644 index 000000000..b9bc65ea4 --- /dev/null +++ b/src/app/shared/components/view-only-link-message/view-only-link-message.component.scss @@ -0,0 +1,3 @@ +:host { + width: 100%; +} diff --git a/src/app/shared/components/view-only-link-message/view-only-link-message.component.spec.ts b/src/app/shared/components/view-only-link-message/view-only-link-message.component.spec.ts new file mode 100644 index 000000000..68dfe046f --- /dev/null +++ b/src/app/shared/components/view-only-link-message/view-only-link-message.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ViewOnlyLinkMessageComponent } from './view-only-link-message.component'; + +describe('ViewOnlyLinkMessageComponent', () => { + let component: ViewOnlyLinkMessageComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ViewOnlyLinkMessageComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ViewOnlyLinkMessageComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/view-only-link-message/view-only-link-message.component.ts b/src/app/shared/components/view-only-link-message/view-only-link-message.component.ts new file mode 100644 index 000000000..a9766134b --- /dev/null +++ b/src/app/shared/components/view-only-link-message/view-only-link-message.component.ts @@ -0,0 +1,23 @@ +import { TranslatePipe } from '@ngx-translate/core'; + +import { Button } from 'primeng/button'; +import { Message } from 'primeng/message'; + +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + selector: 'osf-view-only-link-message', + imports: [Message, TranslatePipe, Button], + templateUrl: './view-only-link-message.component.html', + styleUrl: './view-only-link-message.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ViewOnlyLinkMessageComponent { + handleLeaveViewOnlyView(): void { + const currentUrl = new URL(window.location.href); + currentUrl.searchParams.delete('view_only'); + + window.history.pushState(null, '', currentUrl.toString()); + window.location.reload(); + } +} diff --git a/src/app/shared/enums/file-menu-type.enum.ts b/src/app/shared/enums/file-menu-type.enum.ts index 7bfebff5d..f4d0d97f2 100644 --- a/src/app/shared/enums/file-menu-type.enum.ts +++ b/src/app/shared/enums/file-menu-type.enum.ts @@ -1,9 +1,9 @@ export enum FileMenuType { - Download = 1, - Copy, - Move, - Delete, - Rename, - Share, - Embed, + Download = 'download', + Copy = 'copy', + Move = 'move', + Delete = 'delete', + Rename = 'rename', + Share = 'share', + Embed = 'embed', } diff --git a/src/app/shared/helpers/index.ts b/src/app/shared/helpers/index.ts index 220dd043d..3eb49ae4b 100644 --- a/src/app/shared/helpers/index.ts +++ b/src/app/shared/helpers/index.ts @@ -18,3 +18,4 @@ export * from './search-pref-to-json-api-query-params.helper'; export * from './state-error.handler'; export * from './types.helper'; export * from './url-param.helper'; +export * from './view-only.helper'; diff --git a/src/app/shared/helpers/view-only.helper.ts b/src/app/shared/helpers/view-only.helper.ts new file mode 100644 index 000000000..a92d5234d --- /dev/null +++ b/src/app/shared/helpers/view-only.helper.ts @@ -0,0 +1,31 @@ +import { Router } from '@angular/router'; + +export function hasViewOnlyParam(router: Router): boolean { + const currentUrl = router.url; + const routerParams = new URLSearchParams(currentUrl.split('?')[1] || ''); + const windowParams = new URLSearchParams(window.location.search); + + return routerParams.has('view_only') || windowParams.has('view_only'); +} + +export function getViewOnlyParam(router?: Router): string | null { + let currentUrl = ''; + + if (router) { + currentUrl = router.url; + } + + const routerParams = new URLSearchParams(currentUrl.split('?')[1] || ''); + const windowParams = new URLSearchParams(window.location.search); + + return routerParams.get('view_only') || windowParams.get('view_only'); +} + +export function getViewOnlyParamFromUrl(currentUrl?: string): string | null { + if (!currentUrl) return null; + + const routerParams = new URLSearchParams(currentUrl.split('?')[1] || ''); + const windowParams = new URLSearchParams(window.location.search); + + return routerParams.get('view_only') || windowParams.get('view_only'); +} diff --git a/src/app/shared/mappers/activity-logs.mapper.ts b/src/app/shared/mappers/activity-logs.mapper.ts index 0194cf38b..43b73353b 100644 --- a/src/app/shared/mappers/activity-logs.mapper.ts +++ b/src/app/shared/mappers/activity-logs.mapper.ts @@ -1,53 +1,67 @@ -import { ActivityLog, ActivityLogJsonApi, LogContributor, PaginatedData, ResponseJsonApi } from '@shared/models'; +import { + ActivityLog, + ActivityLogJsonApi, + JsonApiResponseWithMeta, + LogContributor, + MetaAnonymousJsonApi, + PaginatedData, +} from '@shared/models'; import { LogContributorJsonApi } from '@shared/models/activity-logs/activity-logs-json-api.model'; export class ActivityLogsMapper { - static fromActivityLogJsonApi(log: ActivityLogJsonApi): ActivityLog { + static fromActivityLogJsonApi(log: ActivityLogJsonApi, isAnonymous?: boolean): ActivityLog { + const params = log.attributes.params ?? {}; + const contributors = params.contributors ?? []; return { id: log.id, type: log.type, action: log.attributes.action, date: log.attributes.date, params: { - contributors: log.attributes.params.contributors.map((contributor) => this.fromContributorJsonApi(contributor)), - license: log.attributes.params.license, - tag: log.attributes.params.tag, - institution: log.attributes.params.institution, - paramsNode: { - id: log.attributes.params.params_node.id, - title: log.attributes.params.params_node.title, - }, - paramsProject: log.attributes.params.params_project, - pointer: log.attributes.params.pointer + contributors: contributors.length + ? contributors.map((contributor) => this.fromContributorJsonApi(contributor)) + : [], + license: params.license, + tag: params.tag, + institution: params.institution, + paramsNode: params.params_node ? { - category: log.attributes.params.pointer.category, - id: log.attributes.params.pointer.id, - title: log.attributes.params.pointer.title, - url: log.attributes.params.pointer.url, + id: params.params_node.id, + title: params.params_node.title, + } + : { id: '', title: '' }, + paramsProject: params.params_project, + pointer: params.pointer + ? { + category: params.pointer.category, + id: params.pointer.id, + title: params.pointer.title, + url: params.pointer.url, } : null, - preprintProvider: log.attributes.params.preprint_provider, - addon: log.attributes.params.addon, - anonymousLink: log.attributes.params.anonymous_link, - file: log.attributes.params.file, - wiki: log.attributes.params.wiki, - destination: log.attributes.params.destination, - identifiers: log.attributes.params.identifiers, - kind: log.attributes.params.kind, - oldPage: log.attributes.params.old_page, - page: log.attributes.params.page, - pageId: log.attributes.params.page_id, - path: log.attributes.params.path, - urls: log.attributes.params.urls, - preprint: log.attributes.params.preprint, - source: log.attributes.params.source, - titleNew: log.attributes.params.title_new, - titleOriginal: log.attributes.params.title_original, - updatedFields: log.attributes.params.updated_fields, - value: log.attributes.params.value, - version: log.attributes.params.version, - githubUser: log.attributes.params.github_user, + preprintProvider: params.preprint_provider, + addon: params.addon, + anonymousLink: params.anonymous_link, + file: params.file, + wiki: params.wiki, + destination: params.destination, + identifiers: params.identifiers, + kind: params.kind, + oldPage: params.old_page, + page: params.page, + pageId: params.page_id, + path: params.path, + urls: params.urls, + preprint: params.preprint, + source: params.source, + titleNew: params.title_new, + titleOriginal: params.title_original, + updatedFields: params.updated_fields, + value: params.value, + version: params.version, + githubUser: params.github_user, }, + isAnonymous, embeds: log.embeds ? { originalNode: log.embeds.original_node?.data @@ -134,10 +148,14 @@ export class ActivityLogsMapper { }; } - static fromGetActivityLogsResponse(logs: ResponseJsonApi): PaginatedData { + static fromGetActivityLogsResponse( + logs: JsonApiResponseWithMeta + ): PaginatedData { + const isAnonymous = logs.meta.anonymous ?? false; return { - data: logs.data.map((log) => this.fromActivityLogJsonApi(log)), + data: logs.data.map((log) => this.fromActivityLogJsonApi(log, isAnonymous)), totalCount: logs.meta.total ?? 0, + isAnonymous, }; } diff --git a/src/app/shared/mappers/registration/page-schema.mapper.ts b/src/app/shared/mappers/registration/page-schema.mapper.ts index c82a2b454..a9ba2c0ef 100644 --- a/src/app/shared/mappers/registration/page-schema.mapper.ts +++ b/src/app/shared/mappers/registration/page-schema.mapper.ts @@ -8,6 +8,10 @@ export class PageSchemaMapper { let currentQuestion: Question | null = null; let currentSection: Section | null = null; + if (!response?.data || !Array.isArray(response.data)) { + return pages; + } + response.data.map((item) => { switch (item.attributes.block_type) { case BlockType.PageHeading: diff --git a/src/app/shared/mappers/resource-overview.mappers.ts b/src/app/shared/mappers/resource-overview.mappers.ts index e422d7fd5..3b68db002 100644 --- a/src/app/shared/mappers/resource-overview.mappers.ts +++ b/src/app/shared/mappers/resource-overview.mappers.ts @@ -3,7 +3,7 @@ import { RegistryOverview, RegistrySubject } from '@osf/features/registry/models import { Institution, ResourceOverview } from '../models'; -export function MapProjectOverview(project: ProjectOverview): ResourceOverview { +export function MapProjectOverview(project: ProjectOverview, isAnonymous = false): ResourceOverview { return { id: project.id, type: project.type, @@ -37,13 +37,15 @@ export function MapProjectOverview(project: ProjectOverview): ResourceOverview { affiliatedInstitutions: project.affiliatedInstitutions?.filter(Boolean) || undefined, forksCount: project.forksCount || 0, viewOnlyLinksCount: project.viewOnlyLinksCount || 0, + isAnonymous, }; } export function MapRegistryOverview( registry: RegistryOverview, subjects: RegistrySubject[], - institutions: Institution[] + institutions: Institution[], + isAnonymous = false ): ResourceOverview { return { id: registry.id, @@ -78,5 +80,6 @@ export function MapRegistryOverview( customCitation: registry.customCitation, affiliatedInstitutions: institutions, associatedProjectId: registry.associatedProjectId, + isAnonymous, }; } diff --git a/src/app/shared/mappers/view-only-links.mapper.ts b/src/app/shared/mappers/view-only-links.mapper.ts index 3dd1b12c6..8e6153ff8 100644 --- a/src/app/shared/mappers/view-only-links.mapper.ts +++ b/src/app/shared/mappers/view-only-links.mapper.ts @@ -9,7 +9,7 @@ export class ViewOnlyLinksMapper { static fromResponse(response: ViewOnlyLinksResponseJsonApi, projectId: string): PaginatedViewOnlyLinksModel { const items: ViewOnlyLinkModel[] = response.data.map((item) => ({ id: item.id, - link: `${document.baseURI}project/${projectId}/overview?view_only=${item.attributes.key}`, + link: `${document.baseURI}${projectId}/overview?view_only=${item.attributes.key}`, dateCreated: item.attributes.date_created, key: item.attributes.key, name: item.attributes.name, @@ -35,7 +35,7 @@ export class ViewOnlyLinksMapper { const mappedItem: ViewOnlyLinkModel = { id: item.id, - link: `${document.baseURI}project/${projectId}/overview?view_only=${item.attributes.key}`, + link: `${document.baseURI}${projectId}/overview?view_only=${item.attributes.key}`, dateCreated: item.attributes.date_created, key: item.attributes.key, name: item.attributes.name, diff --git a/src/app/shared/models/activity-logs/activity-logs-json-api.model.ts b/src/app/shared/models/activity-logs/activity-logs-json-api.model.ts index 4554cd5f3..bd3eb6472 100644 --- a/src/app/shared/models/activity-logs/activity-logs-json-api.model.ts +++ b/src/app/shared/models/activity-logs/activity-logs-json-api.model.ts @@ -5,7 +5,7 @@ export interface ActivityLogJsonApi { action: string; date: string; params: { - contributors: LogContributorJsonApi[]; + contributors?: LogContributorJsonApi[]; license?: string; tag?: string; institution?: { @@ -84,6 +84,7 @@ export interface ActivityLogJsonApi { }; meta: { total: number; + anonymous: boolean; }; } diff --git a/src/app/shared/models/activity-logs/activity-logs.model.ts b/src/app/shared/models/activity-logs/activity-logs.model.ts index 34b133518..0770d466f 100644 --- a/src/app/shared/models/activity-logs/activity-logs.model.ts +++ b/src/app/shared/models/activity-logs/activity-logs.model.ts @@ -74,6 +74,7 @@ export interface ActivityLog { user?: User; linkedNode?: LinkedNode; }; + isAnonymous?: boolean; } interface Pointer { diff --git a/src/app/shared/models/common/json-api.model.ts b/src/app/shared/models/common/json-api.model.ts index 6a6b8fe01..0fcf8960f 100644 --- a/src/app/shared/models/common/json-api.model.ts +++ b/src/app/shared/models/common/json-api.model.ts @@ -33,6 +33,12 @@ export interface MetaJsonApi { version: string; } +export interface MetaAnonymousJsonApi { + total?: number; + per_page?: number; + anonymous: boolean; +} + export interface PaginationLinksJsonApi { self?: string; first?: string | null; diff --git a/src/app/shared/models/files/get-files-response.model.ts b/src/app/shared/models/files/get-files-response.model.ts index b2daf7a2a..7ec75c487 100644 --- a/src/app/shared/models/files/get-files-response.model.ts +++ b/src/app/shared/models/files/get-files-response.model.ts @@ -1,7 +1,8 @@ import { FileTargetResponse } from '@osf/features/files/models'; -import { ApiData, JsonApiResponse } from '@shared/models'; +import { ApiData, JsonApiResponse, JsonApiResponseWithMeta, MetaAnonymousJsonApi } from '@shared/models'; export type GetFilesResponse = JsonApiResponse; +export type GetFilesResponseWithMeta = JsonApiResponseWithMeta; export type GetFileResponse = JsonApiResponse; export type FileData = ApiData; export type AddFileResponse = ApiData; diff --git a/src/app/shared/models/paginated-data.model.ts b/src/app/shared/models/paginated-data.model.ts index 0232c0d2c..3705ee9fc 100644 --- a/src/app/shared/models/paginated-data.model.ts +++ b/src/app/shared/models/paginated-data.model.ts @@ -1,4 +1,5 @@ export interface PaginatedData { data: T; totalCount: number; + isAnonymous?: boolean; } diff --git a/src/app/shared/models/resource-overview.model.ts b/src/app/shared/models/resource-overview.model.ts index 8e555ff7f..ae771218d 100644 --- a/src/app/shared/models/resource-overview.model.ts +++ b/src/app/shared/models/resource-overview.model.ts @@ -64,4 +64,5 @@ export interface ResourceOverview { forksCount: number; viewOnlyLinksCount?: number; associatedProjectId?: string; + isAnonymous?: boolean; } diff --git a/src/app/shared/models/toolbar-resource.model.ts b/src/app/shared/models/toolbar-resource.model.ts index a267af212..a1a2d958c 100644 --- a/src/app/shared/models/toolbar-resource.model.ts +++ b/src/app/shared/models/toolbar-resource.model.ts @@ -12,4 +12,5 @@ export interface ToolbarResource { viewOnlyLinksCount: number; forksCount: number; resourceType: ResourceType; + isAnonymous: boolean; } diff --git a/src/app/shared/models/view-only-links/view-only-link.model.ts b/src/app/shared/models/view-only-links/view-only-link.model.ts index fa84b83c5..c759bcad4 100644 --- a/src/app/shared/models/view-only-links/view-only-link.model.ts +++ b/src/app/shared/models/view-only-links/view-only-link.model.ts @@ -22,6 +22,12 @@ export interface ViewOnlyLinkModel { anonymous: boolean; } +export interface ViewOnlyLinkComponent { + id: string; + title: string; + isCurrentProject: boolean; +} + export interface PaginatedViewOnlyLinksModel { items: ViewOnlyLinkModel[]; total: number; diff --git a/src/app/shared/models/wiki/wiki.model.ts b/src/app/shared/models/wiki/wiki.model.ts index fa779a7c1..1f90b38d0 100644 --- a/src/app/shared/models/wiki/wiki.model.ts +++ b/src/app/shared/models/wiki/wiki.model.ts @@ -1,16 +1,22 @@ -import { JsonApiResponse } from '@osf/shared/models'; +import { JsonApiResponse, JsonApiResponseWithMeta, MetaAnonymousJsonApi } from '@osf/shared/models'; export enum WikiModes { View = 'view', Edit = 'edit', Compare = 'compare', } + export interface Wiki { id: string; name: string; kind: string; } +export interface WikisWithMeta { + wikis: Wiki[]; + meta: MetaAnonymousJsonApi; +} + export interface WikiVersion { id: string; createdAt: string; @@ -91,6 +97,11 @@ export interface WikiJsonApiResponse extends JsonApiResponse { + data: WikiGetResponse[]; +} + export interface ComponentsWikiJsonApiResponse extends JsonApiResponse { data: ComponentsWikiGetResponse[]; } diff --git a/src/app/shared/services/activity-logs/activity-log-display.service.ts b/src/app/shared/services/activity-logs/activity-log-display.service.ts index 2f37c600f..9b32f126b 100644 --- a/src/app/shared/services/activity-logs/activity-log-display.service.ts +++ b/src/app/shared/services/activity-logs/activity-log-display.service.ts @@ -37,7 +37,7 @@ export class ActivityLogDisplayService { identifiers: this.formatter.buildIdentifiers(log), institution: this.formatter.buildInstitution(log), kind: log.params.kind, - license: log.params.license, + license: log.params.license || '', node: this.formatter.buildNode(log), oldPage: this.formatter.buildOldPage(log), page: this.formatter.buildPage(log), diff --git a/src/app/shared/services/activity-logs/activity-log-formatter.service.ts b/src/app/shared/services/activity-logs/activity-log-formatter.service.ts index 909e397f9..027c9cde7 100644 --- a/src/app/shared/services/activity-logs/activity-log-formatter.service.ts +++ b/src/app/shared/services/activity-logs/activity-log-formatter.service.ts @@ -226,7 +226,8 @@ export class ActivityLogFormatterService { } buildNode(log: ActivityLog): string { - return this.urlBuilder.buildNodeUrl(log); + const nodeUrl = this.urlBuilder.buildNodeUrl(log); + return nodeUrl || this.translateService.instant('activityLog.defaults.aProject'); } buildEmbeddedNode(log: ActivityLog): string { diff --git a/src/app/shared/services/activity-logs/activity-log-url-builder.service.ts b/src/app/shared/services/activity-logs/activity-log-url-builder.service.ts index 5148a0acd..8cb72f503 100644 --- a/src/app/shared/services/activity-logs/activity-log-url-builder.service.ts +++ b/src/app/shared/services/activity-logs/activity-log-url-builder.service.ts @@ -16,7 +16,7 @@ export class ActivityLogUrlBuilderService { const user = log.embeds?.user; const githubUser = log.params.githubUser; - if (user?.id) { + if (user?.id && !log.params.anonymousLink && !log.isAnonymous) { return this.buildAHrefElement(`/${user.id}`, user.fullName); } else if (user?.fullName) { return user.fullName; @@ -32,7 +32,11 @@ export class ActivityLogUrlBuilderService { return ''; } - return this.buildAHrefElement(`project/${log.params.paramsNode.id}`, log.params.paramsNode.title); + if (log.params.anonymousLink || log.isAnonymous) { + return log.params.paramsNode.title; + } + + return this.buildAHrefElement(`/${log.params.paramsNode.id}`, log.params.paramsNode.title); } buildInstitutionUrl(log: ActivityLog): string { diff --git a/src/app/shared/services/activity-logs/activity-logs.service.ts b/src/app/shared/services/activity-logs/activity-logs.service.ts index 2f7098c7e..a8f847498 100644 --- a/src/app/shared/services/activity-logs/activity-logs.service.ts +++ b/src/app/shared/services/activity-logs/activity-logs.service.ts @@ -4,7 +4,13 @@ import { map } from 'rxjs/operators'; import { inject, Injectable } from '@angular/core'; import { ActivityLogsMapper } from '@shared/mappers/activity-logs.mapper'; -import { ActivityLog, ActivityLogJsonApi, PaginatedData, ResponseJsonApi } from '@shared/models'; +import { + ActivityLog, + ActivityLogJsonApi, + JsonApiResponseWithMeta, + MetaAnonymousJsonApi, + PaginatedData, +} from '@shared/models'; import { JsonApiService } from '@shared/services/json-api.service'; import { environment } from 'src/environments/environment'; @@ -24,7 +30,7 @@ export class ActivityLogsService { }; return this.jsonApiService - .get>(url, params) + .get>(url, params) .pipe(map((res) => ActivityLogsMapper.fromGetActivityLogsResponse(res))); } } diff --git a/src/app/shared/services/files.service.ts b/src/app/shared/services/files.service.ts index 80654475e..aed2e7ddb 100644 --- a/src/app/shared/services/files.service.ts +++ b/src/app/shared/services/files.service.ts @@ -31,7 +31,9 @@ import { GetConfiguredStorageAddonsJsonApi, GetFileResponse, GetFilesResponse, + GetFilesResponseWithMeta, JsonApiResponse, + MetaAnonymousJsonApi, OsfFile, OsfFileVersion, } from '@shared/models'; @@ -56,7 +58,11 @@ export class FilesService { [ResourceType.Registration, 'registrations'], ]); - getFiles(filesLink: string, search: string, sort: string): Observable { + getFiles( + filesLink: string, + search: string, + sort: string + ): Observable<{ files: OsfFile[]; meta?: MetaAnonymousJsonApi }> { const params: Record = { sort: sort, 'fields[files]': this.filesFields, @@ -64,12 +70,14 @@ export class FilesService { }; return this.jsonApiService - .get(`${filesLink}`, params) - .pipe(map((response) => MapFiles(response.data))); + .get(`${filesLink}`, params) + .pipe(map((response) => ({ files: MapFiles(response.data), meta: response.meta }))); } - getFolders(folderLink: string): Observable { - return this.jsonApiService.get(`${folderLink}`).pipe(map((response) => MapFiles(response.data))); + getFolders(folderLink: string): Observable<{ files: OsfFile[]; meta?: MetaAnonymousJsonApi }> { + return this.jsonApiService + .get(`${folderLink}`) + .pipe(map((response) => ({ files: MapFiles(response.data), meta: response.meta }))); } getFilesWithoutFiltering(filesLink: string): Observable { diff --git a/src/app/shared/services/view-only-links.service.ts b/src/app/shared/services/view-only-links.service.ts index 7933f3023..71b6c9559 100644 --- a/src/app/shared/services/view-only-links.service.ts +++ b/src/app/shared/services/view-only-links.service.ts @@ -34,7 +34,7 @@ export class ViewOnlyLinksService { getViewOnlyLinksData(projectId: string, resourceType: ResourceType): Observable { const resourcePath = this.urlMap.get(resourceType); - const params: Record = { embed: 'creator' }; + const params: Record = { 'embed[]': ['creator', 'nodes'] }; return this.jsonApiService .get(`${environment.apiUrl}/${resourcePath}/${projectId}/view_only_links/`, params) @@ -48,7 +48,7 @@ export class ViewOnlyLinksService { ): Observable { const resourcePath = this.urlMap.get(resourceType); const data = { data: { ...payload } }; - const params: Record = { embed: 'creator' }; + const params: Record = { 'embed[]': ['creator', 'nodes'] }; return this.jsonApiService .post< diff --git a/src/app/shared/services/wiki.service.ts b/src/app/shared/services/wiki.service.ts index e6e6fdeb8..55ebdc89a 100644 --- a/src/app/shared/services/wiki.service.ts +++ b/src/app/shared/services/wiki.service.ts @@ -5,7 +5,7 @@ import { HttpClient } from '@angular/common/http'; import { inject, Injectable } from '@angular/core'; import { JsonApiService } from '@osf/shared/services'; -import { JsonApiResponse } from '@shared/models'; +import { JsonApiResponse, WikisWithMeta } from '@shared/models'; import { ResourceType } from '../enums'; import { WikiMapper } from '../mappers/wiki'; @@ -15,7 +15,7 @@ import { HomeWikiJsonApiResponse, Wiki, WikiGetResponse, - WikiJsonApiResponse, + WikiJsonApiResponseWithMeta, WikiVersion, WikiVersionJsonApiResponse, } from '../models'; @@ -90,11 +90,14 @@ export class WikiService { ); } - getWikiList(resourceType: ResourceType, resourceId: string): Observable { + getWikiList(resourceType: ResourceType, resourceId: string): Observable { const baseUrl = this.getBaseUrl(resourceType, resourceId); - return this.jsonApiService - .get(baseUrl) - .pipe(map((response) => response.data.map((wiki) => WikiMapper.fromGetWikiResponse(wiki)))); + return this.jsonApiService.get(baseUrl).pipe( + map((response) => ({ + wikis: response.data.map((wiki) => WikiMapper.fromGetWikiResponse(wiki)), + meta: response.meta, + })) + ); } getComponentsWikiList(resourceType: ResourceType, resourceId: string): Observable { diff --git a/src/app/shared/stores/wiki/wiki.model.ts b/src/app/shared/stores/wiki/wiki.model.ts index e12507297..e8a09223d 100644 --- a/src/app/shared/stores/wiki/wiki.model.ts +++ b/src/app/shared/stores/wiki/wiki.model.ts @@ -22,4 +22,5 @@ export interface WikiStateModel { wikiVersions: AsyncStateModel; versionContent: AsyncStateModel; compareVersionContent: AsyncStateModel; + isAnonymous: boolean; } diff --git a/src/app/shared/stores/wiki/wiki.selectors.ts b/src/app/shared/stores/wiki/wiki.selectors.ts index 0d5dc024c..02315bdf0 100644 --- a/src/app/shared/stores/wiki/wiki.selectors.ts +++ b/src/app/shared/stores/wiki/wiki.selectors.ts @@ -85,4 +85,9 @@ export class WikiSelectors { static getPreviewContent(state: WikiStateModel): string { return state.previewContent; } + + @Selector([WikiState]) + static isWikiAnonymous(state: WikiStateModel): boolean { + return state.isAnonymous; + } } diff --git a/src/app/shared/stores/wiki/wiki.state.ts b/src/app/shared/stores/wiki/wiki.state.ts index c10607a81..d876bc4a5 100644 --- a/src/app/shared/stores/wiki/wiki.state.ts +++ b/src/app/shared/stores/wiki/wiki.state.ts @@ -62,6 +62,7 @@ const DefaultState: WikiStateModel = { isLoading: false, error: null, }, + isAnonymous: false, }; @State({ @@ -161,6 +162,7 @@ export class WikiState { wikiVersions: { ...DefaultState.wikiVersions }, versionContent: { ...DefaultState.versionContent }, compareVersionContent: { ...DefaultState.compareVersionContent }, + isAnonymous: DefaultState.isAnonymous, }); } @@ -189,13 +191,14 @@ export class WikiState { }); return this.wikiService.getWikiList(action.resourceType, action.resourceId).pipe( - tap((list) => { + tap((response) => { ctx.patchState({ wikiList: { - data: [...list], + data: [...response.wikis], isLoading: false, error: null, }, + isAnonymous: response.meta?.anonymous ?? false, }); }), map((wiki) => wiki), diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 47d2e714f..984741ab2 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -46,7 +46,8 @@ "preview": "Preview", "continueUpdate": "Continue Update", "editAndResubmit": "Edit And Resubmit", - "createNewVersion": "Create New Version" + "createNewVersion": "Create New Version", + "leaveThisView": "Leave this view" }, "search": { "title": "Search", @@ -129,7 +130,8 @@ "addTag": "Add tags" }, "hint": { - "tagSeparators": "Use enter or comma to create a tag." + "tagSeparators": "Use enter or comma to create a tag.", + "viewOnlyLinksBanner": "You are viewing OSF through a view-only link, which may limit the data you have permission to see." } }, "navigation": { @@ -389,6 +391,8 @@ "anonymous": "Anonymous" }, "viewOnlyLinkCreated": "View only link successfully created.", + "viewOnlyLinkDeleted": "View only link successfully deleted.", + "viewOnlyLinkCurrentProject": "(current component)", "anonymizeContributorList": "Anonymize contributor list for this link (e.g., for blind peer review).", "ensureNoInformation": "Ensure the wiki pages, files, registration forms and add-ons do not contain identifying information.", "linkName": "Link name", @@ -570,6 +574,7 @@ "rejected": "Rejected from\u00A0" }, "metadata": { + "anonymousContributors": "Anonymous Contributors", "title": "Metadata", "contributors": "Contributors", "description": "Description", @@ -2656,24 +2661,25 @@ }, "activityLog": { "defaults": { - "preprint": "preprint", - "preprintPlural": "preprints", + "preprint": "Preprint", + "preprintPlural": "Preprints", "anonymousA": "a", - "anonymousAn": "an", + "anonymousAn": "an anonymous", "someUsers": "some users", - "contributorsAnd": " and ", + "contributorsAnd": ", and ", "contributorsOthers": " others", "aNewNameLocation": "a new name/location", - "materialized": "{{materialized}} {{addon}}", + "materialized": "{{materialized}} in {{addon}}", "aTitle": "a title", "aNameLocation": "a name/location", - "pageTitle": "page title", + "pageTitle": "a title", "aFile": "a file", "folder": "folder", "file": "file", "aUser": "A user", - "fileOn": "file {{file}}", - "wikiOn": "wiki {{wiki}}", + "aProject": "a project", + "fileOn": "on {{file}}", + "wikiOn": "on wiki page {{wiki}}", "field": "field", "updatedFields": "{{old}} to {{new}}", "uncategorized": "Uncategorized", @@ -2695,7 +2701,7 @@ "coi_statement_updated": "{{user}} changed the conflict of interest statement for {{preprint}}.", "comment_added": "{{user}} added a comment {{commentLocation}} in {{node}}", "comment_removed": "{{user}} deleted a comment {{commentLocation}} in {{node}}", - "comment_restored": "{{user}} restored a comment {{commentLocation}} in {{node}}", + "comment_restored": "{{user}} restored a comment {{commentLocation}} in {{node}}", "comment_updated": "{{user}} updated a comment {{commentLocation}} in {{node}}", "contributor_added": "{{user}} added {{contributors}} as contributor(s) to {{node}}", "contributor_removed": "{{user}} removed {{contributors}} as contributor(s) from {{node}}", @@ -2756,7 +2762,7 @@ "pointer_removed": "{{user}} removed a link to {{pointerCategory}} {{pointer}}", "preprint_file_updated": "{{user}} updated the primary file of this {{preprint}} on {{preprintProvider}} {{preprintWordPlural}}", "preprint_initiated": "{{user}} made {{node}} a {{preprint}} on {{preprintProvider}} {{preprintWordPlural}}", - "preprint_license_updated": "{{user}} updated the license of this {{preprint}} on {{preprintProvider}} {{preprintWordPlural}} {{license}}", + "preprint_license_updated": "{{user}} updated the license of this {{preprint}} on {{preprintProvider}} {{preprintWordPlural}} to {{license}}", "prereg_links_info_updated": "{{user}} has updated their preregistration links to {{value}}", "prereg_links_updated": "{{user}} has updated their preregistration data links", "prereg_registration_initiated": "{{user}} submitted for review to the Preregistration Challenge a registration of {{node}}",