Skip to content
10 changes: 10 additions & 0 deletions config/config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,8 @@ homePage:
pageSize: 5
# Enable or disable the Discover filters on the homepage
showDiscoverFilters: false
# Enable or disable the dynamic layout configuration on the homepage
enableDynamicLayout: false

# Item Config
item:
Expand Down Expand Up @@ -714,6 +716,9 @@ accessibility:
# Configuration for layout customization of metadata rendering in Item page
# Currently only the authority reference config is available, more will follow with the integration of the so called CRIS layout.
layout:
# Enable or disable the explore pages feature (/explore/:id routes and their navbar menu entries).
# When false, explore routes redirect to 404 and the explore menu is hidden.
enableExplorePages: false
# Configuration of icons and styles to be used for each authority controlled link
authorityRef:
- entityType: DEFAULT
Expand Down Expand Up @@ -761,6 +766,11 @@ layout:
type: attribute
- name: checksum
type: attribute
# Navbar layout configuration
navbar:
# If true, show the "Community and Collections" link in the navbar; otherwise, show it in the admin sidebar
showCommunityCollection: true


# Configuration for customization of search results
searchResults:
Expand Down
4 changes: 4 additions & 0 deletions src/app/app-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,10 @@ export const APP_ROUTES: Route[] = [
.then((m) => m.ROUTES),
canActivate: [notAuthenticatedGuard],
},
{
path: 'explore',
loadChildren: () => import('./explore-page/explore-routes').then((m) => m.ROUTES),
},
{ path: '**', pathMatch: 'full', component: ThemedPageNotFoundComponent, data: { title: '404.page-not-found' } },
],
},
Expand Down
4 changes: 4 additions & 0 deletions src/app/app.menus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { buildMenuStructure } from './shared/menu/menu.structure';
import { MenuID } from './shared/menu/menu-id.model';
import { MenuRoute } from './shared/menu/menu-route.model';
import { AccessControlMenuProvider } from './shared/menu/providers/access-control.menu';
import { AdminCommunityListMenuProvider } from './shared/menu/providers/admin-community-list.menu';
import { AdminSearchMenuProvider } from './shared/menu/providers/admin-search.menu';
import { AuditLogsMenuProvider } from './shared/menu/providers/audit-item.menu';
import { AuditOverviewMenuProvider } from './shared/menu/providers/audit-overview.menu';
Expand All @@ -25,6 +26,7 @@ import { EditMenuProvider } from './shared/menu/providers/edit.menu';
import { EditCMSMetadataMenuProvider } from './shared/menu/providers/edit-cms-metadata.menu';
import { EditItemMenuProvider } from './shared/menu/providers/edit-item-details.menu';
import { EditUserAgreementMenuProvider } from './shared/menu/providers/edit-user-agreement.menu';
import { ExploreMenuProvider } from './shared/menu/providers/explore.menu';
import { ExportMenuProvider } from './shared/menu/providers/export.menu';
import { HealthMenuProvider } from './shared/menu/providers/health.menu';
import { ImportMenuProvider } from './shared/menu/providers/import.menu';
Expand Down Expand Up @@ -61,8 +63,10 @@ export const MENUS = buildMenuStructure({
CommunityListMenuProvider,
BrowseMenuProvider,
StatisticsMenuProvider,
ExploreMenuProvider,
],
[MenuID.ADMIN]: [
AdminCommunityListMenuProvider,
NewMenuProvider,
EditMenuProvider,
ImportMenuProvider,
Expand Down
Empty file.
47 changes: 47 additions & 0 deletions src/app/core/data/section-data.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Injectable } from '@angular/core';
import { NotificationsService } from '@dspace/core/notification-system/notifications.service';
import { Observable } from 'rxjs';

import { DSONameService } from '../breadcrumbs/dso-name.service';
import { RemoteDataBuildService } from '../cache/builders/remote-data-build.service';
import { ObjectCacheService } from '../cache/object-cache.service';
import { Section } from '../layout/models/section.model';
import { HALEndpointService } from '../shared/hal-endpoint.service';
import { IdentifiableDataService } from './base/identifiable-data.service';
import { SearchDataImpl } from './base/search-data';
import { PaginatedList } from './paginated-list.model';
import { RemoteData } from './remote-data';
import { RequestService } from './request.service';

/**
* A service responsible for fetching data from the REST API on the sections endpoint.
*/
@Injectable({ providedIn: 'root' })
export class SectionDataService extends IdentifiableDataService<Section> {

protected linkPath = 'sections';
private searchData: SearchDataImpl<Section>;

constructor(
protected requestService: RequestService,
protected rdbService: RemoteDataBuildService,
protected objectCache: ObjectCacheService,
protected halService: HALEndpointService,
protected notificationsService: NotificationsService,
protected dsoNameService: DSONameService,
) {
super('sections', requestService, rdbService, objectCache, halService);

this.searchData = new SearchDataImpl(this.linkPath, requestService, rdbService, objectCache, halService, this.responseMsToLive);
}


/**
* Finds all sections configured to be visible in the top navigation bar.
* Uses the 'visibleTopBarSections' search endpoint on the backend.
*/
findVisibleSections(): Observable<RemoteData<PaginatedList<Section>>> {
return this.searchData.searchBy('visibleTopBarSections');
}

}
161 changes: 161 additions & 0 deletions src/app/core/layout/models/section.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import {
autoserialize,
deserialize,
} from 'cerialize';

import { typedObject } from '../../cache/builders/build-decorators';
import { CacheableObject } from '../../cache/cacheable-object.model';
import { HALLink } from '../../shared/hal-link.model';
import { ResourceType } from '../../shared/resource-type';
import { excludeFromEquals } from '../../utilities/equals.decorators';
import { SECTION } from './section.resource-type';

/**
* Describes a type of Section.
*/
@typedObject
export class Section extends CacheableObject {
static type = SECTION;

/**
* The object type
*/
@excludeFromEquals
@autoserialize
type: ResourceType;

/**
* The identifier of this Section.
*/
@autoserialize
id: string;

@autoserialize
componentRows: SectionComponent[][];

@autoserialize
nestedSections: Section[];

/**
* The {@link HALLink}s for this section
*/
@deserialize
_links: {
self: HALLink,
};

}

/**
* Base interface for all section component configurations returned by the REST API.
*/
export interface SectionComponent {
/** The type discriminator identifying which component to render. */
componentType: string;
/** CSS classes applied to the section container (e.g., Bootstrap grid classes). */
style: string;
}

/**
* Configuration for a browse section that displays links to browse indices.
*/
export interface BrowseSection extends SectionComponent {
/** List of browse index names to render as navigation links. */
browseNames: string[];
componentType: 'browse';
}

/**
* Configuration for a top section that displays the most recent/popular items
* from a discovery configuration.
*/
export interface TopSection extends SectionComponent {
/** Name of the discovery configuration to query. */
discoveryConfigurationName: string;
/** Metadata field to sort results by. */
sortField: string;
/** Sort direction ('ASC' or 'DESC'). */
order: string;
/** i18n key for the section title. */
titleKey: string;
componentType: 'top';
/** Maximum number of items to display. */
numberOfItems: number;
/** Whether to show item thumbnails. */
showThumbnails: boolean;
/** Template type to use for rendering the items. */
template: TopSectionTemplateType;
}

/**
* Configuration for an advanced search section with multiple query statements and filter fields.
*/
export interface SearchSection extends SectionComponent {
/** Name of the discovery configuration providing available filters. */
discoveryConfigurationName: string;
componentType: 'search';
/** Type of search interface to render. */
searchType: string;
/** Number of query statement rows to display initially. */
initialStatements: number;
/** Whether to display the section title. */
displayTitle: boolean;
}

/**
* Configuration for a section displaying search facets from a discovery configuration.
*/
export interface FacetSection extends SectionComponent {
/** Name of the discovery configuration to fetch facet values from. */
discoveryConfigurationName: string;
componentType: 'facet';
/** Number of facet boxes to display per row in the grid. */
facetsPerRow: number;
}

/**
* Configuration for a text content section that renders static or metadata-based content.
*/
export interface TextRowSection extends SectionComponent {
/** The content string (can be a metadata key or raw content depending on contentType). */
content: string;
/** The type of content: e.g., 'text-metadata' for metadata lookups or 'text-raw' for static content. */
contentType: string;
componentType: 'text-row';
}

/**
* Configuration for a table-like section showing top items with multiple metadata columns.
*/
export interface MultiColumnTopSection extends SectionComponent {
/** Name of the discovery configuration to query. */
discoveryConfigurationName: string;
/** Metadata field to sort results by. */
sortField: string;
/** Sort direction ('ASC' or 'DESC'). */
order: string;
/** i18n key for the section title. */
titleKey: string;
/** List of column definitions specifying which metadata fields to display. */
columnList: TopSectionColumn[];
componentType: 'multi-column-top';
}

/**
* Column configuration for {@link MultiColumnTopSection} defining which metadata to display.
*/
export interface TopSectionColumn {
/** CSS classes for column width styling. */
style: string;
/** Metadata field to extract the column value from. */
metadataField: string;
/** i18n key for the column header. */
titleKey: string;
}

/**
* Represents the type of template to use for the section
*/
export enum TopSectionTemplateType {
DEFAULT = 'default', // CRIS default template
}
9 changes: 9 additions & 0 deletions src/app/core/layout/models/section.resource-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ResourceType } from '../../shared/resource-type';

/**
* The resource type for Section
*
* Needs to be in a separate file to prevent circular
* dependencies in webpack.
*/
export const SECTION = new ResourceType('section');
2 changes: 2 additions & 0 deletions src/app/core/provide-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
import { EPerson } from './eperson/models/eperson.model';
import { Group } from './eperson/models/group.model';
import { Feedback } from './feedback/models/feedback.model';
import { Section } from './layout/models/section.model';
import { MetadataField } from './metadata/metadata-field.model';
import { MetadataSchema } from './metadata/metadata-schema.model';
import { QualityAssuranceEventObject } from './notifications/qa/models/quality-assurance-event.model';
Expand Down Expand Up @@ -232,4 +233,5 @@ export const models =
CorrectionType,
SupervisionOrder,
SubmissionCustomUrl,
Section,
];
74 changes: 74 additions & 0 deletions src/app/core/services/internal-link.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {
TestBed,
waitForAsync,
} from '@angular/core/testing';

import { InternalLinkService } from './internal-link.service';
import { NativeWindowService } from './window.service';

describe('InternalLinkService', () => {
let service: InternalLinkService;

beforeEach(waitForAsync(() => {
return TestBed.configureTestingModule({
providers: [
InternalLinkService,
{ provide: NativeWindowService, useValue: { nativeWindow: { location: { origin: 'https://currentdomain' } } } },
],
}).compileComponents();
}));

beforeEach(() => {
service = TestBed.inject(InternalLinkService);
});

describe('isLinkInternal', () => {
it('should return true for internal link starting with "/"', () => {
const result = service.isLinkInternal('/my-link');
expect(result).toBe(true);
});

it('should return true for internal link starting with currentURL', () => {
const result = service.isLinkInternal('https://currentdomain/my-link');
expect(result).toBe(true);
});

it('should return true for internal link starting with "currentdomain"', () => {
const result = service.isLinkInternal('currentdomain/my-link');
expect(result).toBe(true);
});

it('should return false for external link', () => {
const result = service.isLinkInternal('https://externaldomain/my-link');
expect(result).toBe(false);
});

it('should return true for internal link without leading "/"', () => {
const result = service.isLinkInternal('my-link');
expect(result).toBe(true);
});
});

describe('transformInternalLink', () => {
it('should transform internal link by removing currentURL', () => {
const result = service.getRelativePath('https://currentdomain/my-link');
expect(result).toBe('/my-link');
});

it('should transform internal link by adding leading "/" if missing', () => {
const result = service.getRelativePath('currentdomain/my-link');
expect(result).toBe('/my-link');
});

it('should return unchanged link for external link', () => {
const result = service.getRelativePath('https://externalDomain/my-link');
expect(result).toBe('/https://externalDomain/my-link');
});

it('should return unchanged link for internal link with leading "/"', () => {
const result = service.getRelativePath('/my-link');
expect(result).toBe('/my-link');
});
});

});
Loading
Loading