Exclude search and browse from Angular SSR (#3709)

* [DURACOM-303] prevent possibly long-lasting search and browse calls in SSR

* [DURACOM-303] implement skeleton component for search results

* [DURACOM-303] add skeleton loader for search results and filters

* [DURACOM-303] minor restyle of skeleton for mobile

* [DURACOM-303] fix lint and tests

* [DURACOM-303] adapt tests

* [DURACOM-303] restyle skeleton, add filter badge skeleton

* [DURACOM-303] add loop for filters count

* [DURACOM-303] add grid layout, make SSR enabling configurable, minor restyle of skeletons

* [DURACOM-303] refactor param, add example of configuration

* [DURACOM-303] rename variable, minor code refactor

* [DURACOM-303] add override possibility with input

* [DURACOM-303] fix SSR check on template and on components missing the environment config. Add descriptive comment for skeleton component. Fix JS error on SSR.

* [DURACOM-303] refactor thumbnail's skeleton style
This commit is contained in:
FrancescoMolinaro
2025-01-23 17:07:15 +01:00
committed by Tim Donohue
parent cb8a7cd402
commit 17ecc592f3
38 changed files with 641 additions and 51 deletions

View File

@@ -25,6 +25,14 @@ ssr:
inlineCriticalCss: false
# Path prefixes to enable SSR for. By default these are limited to paths of primary DSpace objects.
paths: [ '/home', '/items/', '/entities/', '/collections/', '/communities/', '/bitstream/', '/bitstreams/', '/handle/' ]
# Whether to enable rendering of Search component on SSR.
# If set to true the component will be included in the HTML returned from the server side rendering.
# If set to false the component will not be included in the HTML returned from the server side rendering.
enableSearchComponent: false,
# Whether to enable rendering of Browse component on SSR.
# If set to true the component will be included in the HTML returned from the server side rendering.
# If set to false the component will not be included in the HTML returned from the server side rendering.
enableBrowseComponent: false,
# The REST API server settings
# NOTE: these settings define which (publicly available) REST API to use. They are usually
@@ -450,6 +458,12 @@ search:
enabled: false
# List of filters to enable in "Advanced Search" dropdown
filter: [ 'title', 'author', 'subject', 'entityType' ]
#
# Number used to render n UI elements called loading skeletons that act as placeholders.
# These elements indicate that some content will be loaded in their stead.
# Since we don't know how many filters will be loaded before we receive a response from the server we use this parameter for the skeletons count.
# e.g. If we set 5 then 5 loading skeletons will be visualized before the actual filters are retrieved.
defaultFiltersCount: 5
# Notify metrics

14
package-lock.json generated
View File

@@ -67,6 +67,7 @@
"ng2-nouislider": "^2.0.0",
"ngx-infinite-scroll": "^16.0.0",
"ngx-pagination": "6.0.3",
"ngx-skeleton-loader": "^9.0.0",
"ngx-ui-switch": "^14.1.0",
"nouislider": "^15.7.1",
"orejime": "^2.3.1",
@@ -16941,6 +16942,19 @@
"@angular/core": ">=13.0.0"
}
},
"node_modules/ngx-skeleton-loader": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/ngx-skeleton-loader/-/ngx-skeleton-loader-9.0.0.tgz",
"integrity": "sha512-aO4/V6oGdZGNcTjasTg/fwzJJYl/ZmNKgCukOEQdUK3GSFOZtB/3GGULMJuZ939hk3Hzqh1OBiLfIM1SqTfhqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"@angular/common": ">=16.0.0",
"@angular/core": ">=16.0.0"
}
},
"node_modules/ngx-ui-switch": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/ngx-ui-switch/-/ngx-ui-switch-14.1.0.tgz",

View File

@@ -154,6 +154,7 @@
"ng2-nouislider": "^2.0.0",
"ngx-infinite-scroll": "^16.0.0",
"ngx-pagination": "6.0.3",
"ngx-skeleton-loader": "^9.0.0",
"ngx-ui-switch": "^14.1.0",
"nouislider": "^15.7.1",
"orejime": "^2.3.1",

View File

@@ -2,10 +2,13 @@ import { CommonModule } from '@angular/common';
import {
ChangeDetectorRef,
NO_ERRORS_SCHEMA,
PLATFORM_ID,
} from '@angular/core';
import {
ComponentFixture,
fakeAsync,
TestBed,
tick,
waitForAsync,
} from '@angular/core/testing';
import {
@@ -26,6 +29,7 @@ import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-
import { SortDirection } from '../../core/cache/models/sort-options.model';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { PaginationService } from '../../core/pagination/pagination.service';
import { BrowseEntry } from '../../core/shared/browse-entry.model';
import { Community } from '../../core/shared/community.model';
import { Item } from '../../core/shared/item.model';
import { ThemedBrowseByComponent } from '../../shared/browse-by/themed-browse-by.component';
@@ -123,6 +127,7 @@ describe('BrowseByDateComponent', () => {
{ provide: ChangeDetectorRef, useValue: mockCdRef },
{ provide: Store, useValue: {} },
{ provide: APP_CONFIG, useValue: environment },
{ provide: PLATFORM_ID, useValue: 'browser' },
],
schemas: [NO_ERRORS_SCHEMA],
})
@@ -172,4 +177,33 @@ describe('BrowseByDateComponent', () => {
//expect(comp.startsWithOptions[0]).toEqual(new Date().getUTCFullYear());
expect(comp.startsWithOptions[0]).toEqual(1960);
});
describe('when rendered in SSR', () => {
beforeEach(() => {
comp.platformId = 'server';
spyOn((comp as any).browseService, 'getBrowseItemsFor');
});
it('should not call getBrowseItemsFor on init', (done) => {
comp.ngOnInit();
expect((comp as any).browseService.getBrowseItemsFor).not.toHaveBeenCalled();
comp.loading$.subscribe((res) => {
expect(res).toBeFalsy();
done();
});
});
});
describe('when rendered in CSR', () => {
beforeEach(() => {
comp.platformId = 'browser';
spyOn((comp as any).browseService, 'getBrowseItemsFor').and.returnValue(createSuccessfulRemoteDataObject$(new BrowseEntry()));
});
it('should call getBrowseItemsFor on init', fakeAsync(() => {
comp.ngOnInit();
tick(100);
expect((comp as any).browseService.getBrowseItemsFor).toHaveBeenCalled();
}));
});
});

View File

@@ -1,5 +1,6 @@
import {
AsyncPipe,
isPlatformServer,
NgIf,
} from '@angular/common';
import {
@@ -7,6 +8,7 @@ import {
Component,
Inject,
OnInit,
PLATFORM_ID,
} from '@angular/core';
import {
ActivatedRoute,
@@ -17,6 +19,7 @@ import { TranslateModule } from '@ngx-translate/core';
import {
combineLatest as observableCombineLatest,
Observable,
of as observableOf,
} from 'rxjs';
import {
map,
@@ -28,6 +31,7 @@ import {
APP_CONFIG,
AppConfig,
} from '../../../config/app-config.interface';
import { environment } from '../../../environments/environment';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { BrowseService } from '../../core/browse/browse.service';
import {
@@ -99,11 +103,16 @@ export class BrowseByDateComponent extends BrowseByMetadataComponent implements
@Inject(APP_CONFIG) public appConfig: AppConfig,
public dsoNameService: DSONameService,
protected cdRef: ChangeDetectorRef,
@Inject(PLATFORM_ID) public platformId: any,
) {
super(route, browseService, dsoService, paginationService, router, appConfig, dsoNameService);
super(route, browseService, dsoService, paginationService, router, appConfig, dsoNameService, platformId);
}
ngOnInit(): void {
if (!this.renderOnServerSide && !environment.ssr.enableBrowseComponent && isPlatformServer(this.platformId)) {
this.loading$ = observableOf(false);
return;
}
const sortConfig = new SortOptions('default', SortDirection.ASC);
this.startsWithType = StartsWithType.date;
this.currentPagination$ = this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig);

View File

@@ -1,4 +1,4 @@
<section class="comcol-page-browse-section">
<section class="comcol-page-browse-section" *ngIf="(!ssrRenderingDisabled)">
<div class="browse-by-metadata w-100">
<ds-browse-by *ngIf="(loading$ | async) !== true" class="col-xs-12 w-100"
title="{{'browse.title' | translate:{

View File

@@ -1,8 +1,13 @@
import { CommonModule } from '@angular/common';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import {
NO_ERRORS_SCHEMA,
PLATFORM_ID,
} from '@angular/core';
import {
ComponentFixture,
fakeAsync,
TestBed,
tick,
waitForAsync,
} from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
@@ -147,6 +152,7 @@ describe('BrowseByMetadataComponent', () => {
{ provide: ThemeService, useValue: getMockThemeService() },
{ provide: SelectableListService, useValue: {} },
{ provide: HostWindowService, useValue: {} },
{ provide: PLATFORM_ID, useValue: 'browser' },
],
schemas: [NO_ERRORS_SCHEMA],
})
@@ -259,6 +265,35 @@ describe('BrowseByMetadataComponent', () => {
expect(result.fetchThumbnail).toBeTrue();
});
});
describe('when rendered in SSR', () => {
beforeEach(() => {
comp.ssrRenderingDisabled = true;
spyOn((comp as any).browseService, 'getBrowseEntriesFor').and.returnValue(createSuccessfulRemoteDataObject$(null));
});
it('should not call getBrowseEntriesFor on init', (done) => {
comp.ngOnInit();
expect((comp as any).browseService.getBrowseEntriesFor).not.toHaveBeenCalled();
comp.loading$.subscribe((res) => {
expect(res).toBeFalsy();
done();
});
});
});
describe('when rendered in CSR', () => {
beforeEach(() => {
comp.ssrRenderingDisabled = false;
spyOn((comp as any).browseService, 'getBrowseEntriesFor').and.returnValue(createSuccessfulRemoteDataObject$(new BrowseEntry()));
});
it('should call getBrowseEntriesFor on init', fakeAsync(() => {
comp.ngOnInit();
tick(100);
expect((comp as any).browseService.getBrowseEntriesFor).toHaveBeenCalled();
}));
});
});
export function toRemoteData(objects: any[]): Observable<RemoteData<PaginatedList<any>>> {

View File

@@ -1,5 +1,6 @@
import {
AsyncPipe,
isPlatformServer,
NgIf,
} from '@angular/common';
import {
@@ -9,6 +10,7 @@ import {
OnChanges,
OnDestroy,
OnInit,
PLATFORM_ID,
} from '@angular/core';
import {
ActivatedRoute,
@@ -33,6 +35,7 @@ import {
APP_CONFIG,
AppConfig,
} from '../../../config/app-config.interface';
import { environment } from '../../../environments/environment';
import { DSONameService } from '../../core/breadcrumbs/dso-name.service';
import { BrowseService } from '../../core/browse/browse.service';
import { BrowseEntrySearchOptions } from '../../core/browse/browse-entry-search-options.model';
@@ -114,6 +117,11 @@ export class BrowseByMetadataComponent implements OnInit, OnChanges, OnDestroy {
*/
@Input() displayTitle = true;
/**
* Defines whether to fetch search results during SSR execution
*/
@Input() renderOnServerSide: boolean;
scope$: BehaviorSubject<string> = new BehaviorSubject(undefined);
/**
@@ -194,6 +202,10 @@ export class BrowseByMetadataComponent implements OnInit, OnChanges, OnDestroy {
* Observable determining if the loading animation needs to be shown
*/
loading$ = observableOf(true);
/**
* Whether this component should be rendered or not in SSR
*/
ssrRenderingDisabled = false;
public constructor(protected route: ActivatedRoute,
protected browseService: BrowseService,
@@ -202,6 +214,7 @@ export class BrowseByMetadataComponent implements OnInit, OnChanges, OnDestroy {
protected router: Router,
@Inject(APP_CONFIG) public appConfig: AppConfig,
public dsoNameService: DSONameService,
@Inject(PLATFORM_ID) public platformId: any,
) {
this.fetchThumbnails = this.appConfig.browseBy.showThumbnails;
this.paginationConfig = Object.assign(new PaginationComponentOptions(), {
@@ -209,11 +222,15 @@ export class BrowseByMetadataComponent implements OnInit, OnChanges, OnDestroy {
currentPage: 1,
pageSize: this.appConfig.browseBy.pageSize,
});
this.ssrRenderingDisabled = !this.renderOnServerSide && !environment.ssr.enableBrowseComponent && isPlatformServer(this.platformId);
}
ngOnInit(): void {
if (this.ssrRenderingDisabled) {
this.loading$ = observableOf(false);
return;
}
const sortConfig = new SortOptions('default', SortDirection.ASC);
this.updatePage(getBrowseSearchOptions(this.defaultBrowseId, this.paginationConfig, sortConfig));
this.currentPagination$ = this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig);
@@ -336,7 +353,6 @@ export class BrowseByMetadataComponent implements OnInit, OnChanges, OnDestroy {
this.paginationService.clearPagination(this.paginationConfig.id);
}
}
/**

View File

@@ -5,7 +5,9 @@ import {
import { NO_ERRORS_SCHEMA } from '@angular/core';
import {
ComponentFixture,
fakeAsync,
TestBed,
tick,
waitForAsync,
} from '@angular/core/testing';
import {
@@ -23,6 +25,7 @@ import { BrowseService } from '../../core/browse/browse.service';
import { DSpaceObjectDataService } from '../../core/data/dspace-object-data.service';
import { ItemDataService } from '../../core/data/item-data.service';
import { PaginationService } from '../../core/pagination/pagination.service';
import { BrowseEntry } from '../../core/shared/browse-entry.model';
import { Community } from '../../core/shared/community.model';
import { Item } from '../../core/shared/item.model';
import { ThemedBrowseByComponent } from '../../shared/browse-by/themed-browse-by.component';
@@ -81,6 +84,7 @@ describe('BrowseByTitleComponent', () => {
const activatedRouteStub = Object.assign(new ActivatedRouteStub(), {
params: observableOf({}),
queryParams: observableOf({}),
data: observableOf({ metadata: 'title' }),
});
@@ -127,4 +131,35 @@ describe('BrowseByTitleComponent', () => {
expect(result.payload.page).toEqual(mockItems);
});
});
describe('when rendered in SSR', () => {
beforeEach(() => {
comp.platformId = 'server';
spyOn((comp as any).browseService, 'getBrowseItemsFor');
fixture.detectChanges();
});
it('should not call getBrowseItemsFor on init', (done) => {
comp.ngOnInit();
expect((comp as any).browseService.getBrowseItemsFor).not.toHaveBeenCalled();
comp.loading$.subscribe((res) => {
expect(res).toBeFalsy();
done();
});
});
});
describe('when rendered in CSR', () => {
beforeEach(() => {
comp.platformId = 'browser';
fixture.detectChanges();
spyOn((comp as any).browseService, 'getBrowseItemsFor').and.returnValue(createSuccessfulRemoteDataObject$(new BrowseEntry()));
});
it('should call getBrowseItemsFor on init', fakeAsync(() => {
comp.ngOnInit();
tick(100);
expect((comp as any).browseService.getBrowseItemsFor).toHaveBeenCalled();
}));
});
});

View File

@@ -1,5 +1,6 @@
import {
AsyncPipe,
isPlatformServer,
NgIf,
} from '@angular/common';
import {
@@ -8,12 +9,16 @@ import {
} from '@angular/core';
import { Params } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { combineLatest as observableCombineLatest } from 'rxjs';
import {
combineLatest as observableCombineLatest,
of as observableOf,
} from 'rxjs';
import {
map,
take,
} from 'rxjs/operators';
import { environment } from '../../../environments/environment';
import {
SortDirection,
SortOptions,
@@ -59,6 +64,10 @@ import {
export class BrowseByTitleComponent extends BrowseByMetadataComponent implements OnInit {
ngOnInit(): void {
if (!this.renderOnServerSide && !environment.ssr.enableBrowseComponent && isPlatformServer(this.platformId)) {
this.loading$ = observableOf(false);
return;
}
const sortConfig = new SortOptions('dc.title', SortDirection.ASC);
this.currentPagination$ = this.paginationService.getCurrentPagination(this.paginationConfig.id, this.paginationConfig);
this.currentSort$ = this.paginationService.getCurrentSort(this.paginationConfig.id, sortConfig);

View File

@@ -7,6 +7,7 @@ import {
ChangeDetectionStrategy,
Component,
Inject,
PLATFORM_ID,
} from '@angular/core';
import { Router } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
@@ -57,7 +58,8 @@ export class ConfigurationSearchPageComponent extends SearchComponent {
protected routeService: RouteService,
protected router: Router,
@Inject(APP_CONFIG) protected appConfig: AppConfig,
@Inject(PLATFORM_ID) public platformId: any,
) {
super(service, sidebarService, windowService, searchConfigService, routeService, router, appConfig);
super(service, sidebarService, windowService, searchConfigService, routeService, router, appConfig, platformId);
}
}

View File

@@ -6,11 +6,13 @@ import {
} from '@angular/common';
import {
Component,
EventEmitter,
Inject,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
} from '@angular/core';
import { TranslateModule } from '@ngx-translate/core';
import {
@@ -78,6 +80,8 @@ export class SearchFilterComponent implements OnInit, OnChanges, OnDestroy {
*/
@Input() scope: string;
@Output() isVisibilityComputed = new EventEmitter<boolean>();
/**
* True when the filter is 100% collapsed in the UI
*/
@@ -136,11 +140,16 @@ export class SearchFilterComponent implements OnInit, OnChanges, OnDestroy {
this.active$ = this.isActive();
this.collapsed$ = this.isCollapsed();
this.initializeFilter();
this.subs.push(this.appliedFilters$.pipe(take(1)).subscribe((selectedValues: AppliedFilter[]) => {
this.subs.push(
this.appliedFilters$.subscribe((selectedValues: AppliedFilter[]) => {
if (isNotEmpty(selectedValues)) {
this.filterService.expand(this.filter.name);
}
}));
}),
this.getIsActive().pipe(take(1)).subscribe(() => {
this.isVisibilityComputed.emit(true);
}),
);
}
ngOnChanges(): void {
@@ -223,6 +232,16 @@ export class SearchFilterComponent implements OnInit, OnChanges, OnDestroy {
* @returns {Observable<boolean>} Emits true whenever a given filter config should be shown
*/
isActive(): Observable<boolean> {
return this.getIsActive().pipe(
startWith(true),
);
}
/**
* Return current filter visibility
* @returns {Observable<boolean>} Emits true whenever a given filter config should be shown
*/
private getIsActive(): Observable<boolean> {
return combineLatest([
this.appliedFilters$,
this.searchConfigService.searchOptions,
@@ -243,7 +262,6 @@ export class SearchFilterComponent implements OnInit, OnChanges, OnDestroy {
);
}
}),
startWith(true),
);
}
}

View File

@@ -1,10 +1,24 @@
<h3 *ngIf="inPlaceSearch">{{filterLabel+'.filters.head' | translate}}</h3>
<h2 *ngIf="!inPlaceSearch">{{filterLabel+'.filters.head' | translate}}</h2>
<div *ngIf="(filters | async)?.hasSucceeded">
<div *ngFor="let filter of (filters | async)?.payload; trackBy: trackUpdate">
<ds-search-filter [scope]="currentScope" [filter]="filter" [inPlaceSearch]="inPlaceSearch" [refreshFilters]="refreshFilters"></ds-search-filter>
@if (inPlaceSearch) {
<h3>{{filterLabel+'.filters.head' | translate}}</h3>
} @else {
<h2>{{filterLabel+'.filters.head' | translate}}</h2>
}
@if ((filters | async)?.hasSucceeded) {
<div [class.visually-hidden]="filtersWithComputedVisibility !== (filters | async)?.payload?.length">
@for (filter of (filters | async)?.payload; track filter.name) {
<ds-search-filter (isVisibilityComputed)="countFiltersWithComputedVisibility($event)" [scope]="currentScope" [filter]="filter" [inPlaceSearch]="inPlaceSearch" [refreshFilters]="refreshFilters"></ds-search-filter>
}
</div>
</div>
<button *ngIf="inPlaceSearch" class="btn btn-primary" [routerLink]="[searchLink]" [queryParams]="clearParams | async" (click)="minimizeFilters()" queryParamsHandling="merge" role="button">
}
@if(filtersWithComputedVisibility !== (filters | async)?.payload?.length) {
<ngx-skeleton-loader [count]="defaultFilterCount"/>
}
@if (inPlaceSearch) {
<button class="btn btn-primary" [routerLink]="[searchLink]" [queryParams]="clearParams | async" (click)="minimizeFilters()" queryParamsHandling="merge" role="button">
<i class="fas fa-undo"></i> {{"search.filters.reset" | translate}}
</button>
}

View File

@@ -1,2 +1,12 @@
@import '../../../../styles/variables';
@import '../../../../styles/mixins';
:host ::ng-deep {
ngx-skeleton-loader .skeleton-loader {
height: var(--ds-filters-skeleton-height);
margin-bottom: var(--ds-filters-skeleton-spacing);
padding: var(--ds-filters-skeleton-spacing);
background-color: var(--bs-light);
box-shadow: none;
}
}

View File

@@ -11,6 +11,8 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { RouterModule } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { APP_CONFIG } from '../../../../config/app-config.interface';
import { environment } from '../../../../environments/environment';
import { SearchService } from '../../../core/shared/search/search.service';
import { SearchFilterService } from '../../../core/shared/search/search-filter.service';
import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-configuration.service';
@@ -41,6 +43,7 @@ describe('SearchFiltersComponent', () => {
{ provide: SearchService, useValue: searchService },
{ provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() },
{ provide: SearchFilterService, useValue: searchFilters },
{ provide: APP_CONFIG, useValue: environment },
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
}).overrideComponent(SearchFiltersComponent, {

View File

@@ -1,8 +1,4 @@
import {
AsyncPipe,
NgFor,
NgIf,
} from '@angular/common';
import { AsyncPipe } from '@angular/common';
import {
Component,
Inject,
@@ -14,19 +10,23 @@ import {
RouterLink,
} from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import {
BehaviorSubject,
Observable,
} from 'rxjs';
import { map } from 'rxjs/operators';
import {
APP_CONFIG,
AppConfig,
} from '../../../../config/app-config.interface';
import { RemoteData } from '../../../core/data/remote-data';
import { SearchService } from '../../../core/shared/search/search.service';
import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service';
import { SearchFilterService } from '../../../core/shared/search/search-filter.service';
import { SEARCH_CONFIG_SERVICE } from '../../../my-dspace-page/my-dspace-configuration.service';
import { currentPath } from '../../utils/route.utils';
import { AdvancedSearchComponent } from '../advanced-search/advanced-search.component';
import { AppliedFilter } from '../models/applied-filter.model';
import { SearchFilterConfig } from '../models/search-filter-config.model';
import { SearchFilterComponent } from './search-filter/search-filter.component';
@@ -36,7 +36,7 @@ import { SearchFilterComponent } from './search-filter/search-filter.component';
styleUrls: ['./search-filters.component.scss'],
templateUrl: './search-filters.component.html',
standalone: true,
imports: [NgIf, NgFor, SearchFilterComponent, RouterLink, AsyncPipe, TranslateModule, AdvancedSearchComponent],
imports: [SearchFilterComponent, RouterLink, AsyncPipe, TranslateModule, NgxSkeletonLoaderModule],
})
/**
@@ -81,15 +81,23 @@ export class SearchFiltersComponent implements OnInit {
*/
searchLink: string;
/**
* Filters for which visibility has been computed
*/
filtersWithComputedVisibility = 0;
subs = [];
filterLabel = 'search';
defaultFilterCount: number;
constructor(
protected searchService: SearchService,
protected searchFilterService: SearchFilterService,
protected router: Router,
@Inject(SEARCH_CONFIG_SERVICE) protected searchConfigService: SearchConfigurationService,
@Inject(APP_CONFIG) protected appConfig: AppConfig,
) {
this.defaultFilterCount = this.appConfig.search.filterPlaceholdersCount ?? 5;
}
ngOnInit(): void {
@@ -125,4 +133,10 @@ export class SearchFiltersComponent implements OnInit {
this.searchFilterService.minimizeAll();
}
}
countFiltersWithComputedVisibility(computed: boolean) {
if (computed) {
this.filtersWithComputedVisibility += 1;
}
}
}

View File

@@ -0,0 +1,38 @@
<div class="row flex-nowrap">
<div [class.mb-2]="(viewMode$ | async) === ViewMode.ListElement" class="info-skeleton col-12">
<ngx-skeleton-loader/>
</div>
</div>
@if((viewMode$ | async) === ViewMode.ListElement) {
@for (result of loadingResults; track result; let first = $first) {
<div [class.my-4]="!first" class="row">
@if(showThumbnails) {
<div class="col-3 col-md-2">
<div class="thumbnail-skeleton position-relative">
<ngx-skeleton-loader/>
</div>
</div>
}
<div [class.col-9]="showThumbnails" [class.col-md-10]="showThumbnails" [class.col-md-12]="!showThumbnails">
<div class="badge-skeleton">
<ngx-skeleton-loader/>
</div>
<div class="text-skeleton">
<ngx-skeleton-loader [count]="textLineCount"/>
</div>
</div>
</div>
}
} @else if ((viewMode$ | async) === ViewMode.GridElement) {
<div class="card-columns row">
@for (result of loadingResults; track result) {
<div class="card-column col col-sm-6 col-lg-4">
<div class="card-skeleton">
<ngx-skeleton-loader/>
</div>
</div>
}
</div>
}

View File

@@ -0,0 +1,56 @@
:host ::ng-deep {
--ds-wrapper-grid-spacing: calc(var(--bs-spacer) / 2);
.info-skeleton, .badge-skeleton, .text-skeleton{
ngx-skeleton-loader .skeleton-loader {
height: var(--ds-search-skeleton-text-height);
}
}
.badge-skeleton, .info-skeleton {
ngx-skeleton-loader .skeleton-loader {
width: var(--ds-search-skeleton-badge-width);
}
}
.info-skeleton {
ngx-skeleton-loader .skeleton-loader {
width: var(--ds-search-skeleton-info-width);
}
}
.thumbnail-skeleton {
max-width: var(--ds-thumbnail-max-width);
height: 100%;
ngx-skeleton-loader .skeleton-loader {
margin-right: var(--ds-search-skeleton-thumbnail-margin);
border-radius: 0;
height: 100%;
}
}
.card-skeleton {
ngx-skeleton-loader .skeleton-loader {
height: var(--ds-search-skeleton-card-height);
}
}
ngx-skeleton-loader .skeleton-loader {
background-color: var(--bs-light);
box-shadow: none;
}
.card-columns {
margin-left: calc(-1 * var(--ds-wrapper-grid-spacing));
margin-right: calc(-1 * var(--ds-wrapper-grid-spacing));
column-gap: 0;
.card-column {
padding-left: var(--ds-wrapper-grid-spacing);
padding-right: var(--ds-wrapper-grid-spacing);
}
}
}

View File

@@ -0,0 +1,32 @@
import {
ComponentFixture,
TestBed,
} from '@angular/core/testing';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { SearchService } from '../../../../core/shared/search/search.service';
import { SearchServiceStub } from '../../../testing/search-service.stub';
import { SearchResultsSkeletonComponent } from './search-results-skeleton.component';
describe('SearchResultsSkeletonComponent', () => {
let component: SearchResultsSkeletonComponent;
let fixture: ComponentFixture<SearchResultsSkeletonComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SearchResultsSkeletonComponent, NgxSkeletonLoaderModule],
providers: [
{ provide: SearchService, useValue: new SearchServiceStub() },
],
})
.compileComponents();
fixture = TestBed.createComponent(SearchResultsSkeletonComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,70 @@
import {
AsyncPipe,
NgForOf,
} from '@angular/common';
import {
Component,
Input,
OnInit,
} from '@angular/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Observable } from 'rxjs';
import { SearchService } from '../../../../core/shared/search/search.service';
import { ViewMode } from '../../../../core/shared/view-mode.model';
import { hasValue } from '../../../empty.util';
@Component({
selector: 'ds-search-results-skeleton',
standalone: true,
imports: [
NgxSkeletonLoaderModule,
AsyncPipe,
NgForOf,
],
templateUrl: './search-results-skeleton.component.html',
styleUrl: './search-results-skeleton.component.scss',
})
/**
* Component to show placeholders for search results while loading, to give a loading feedback to the user without layout shifting.
*/
export class SearchResultsSkeletonComponent implements OnInit {
/**
* Whether the search result contains thumbnail
*/
@Input()
showThumbnails: boolean;
/**
* The number of search result loaded in the current page
*/
@Input()
numberOfResults = 0;
/**
* How many placeholder are displayed for the search result text
*/
@Input()
textLineCount = 2;
/**
* The view mode of the search page
*/
public viewMode$: Observable<ViewMode>;
/**
* Array built from numberOfResults to count and iterate based on index
*/
public loadingResults: number[];
protected readonly ViewMode = ViewMode;
constructor(private searchService: SearchService) {
this.viewMode$ = this.searchService.getViewMode();
}
ngOnInit() {
this.loadingResults = Array.from({ length: this.numberOfResults }, (_, i) => i + 1);
if (!hasValue(this.showThumbnails)) {
// this is needed as the default value of show thumbnails is true but set in lower levels of the DOM.
this.showThumbnails = true;
}
}
}

View File

@@ -1,3 +1,15 @@
@if ((activeFilters$ | async).length > 0 && (appliedFilters$ | async).length === 0) {
<div class="row">
<div class="col-12">
<div class="filters-badge-skeleton-container">
<div class="filter-badge-skeleton">
<ngx-skeleton-loader [count]="(activeFilters$ | async).length" />
</div>
</div>
</div>
</div>
}
<div class="d-flex justify-content-between">
<h1 *ngIf="!disableHeader">{{ (configuration ? configuration + '.search.results.head' : 'search.results.head') | translate }}</h1>
<ds-search-export-csv *ngIf="showCsvExport" [searchConfig]="searchConfig"></ds-search-export-csv>
@@ -19,7 +31,13 @@
(selectObject)="selectObject.emit($event)">
</ds-viewable-collection>
</div>
<ds-loading *ngIf="isLoading()" message="{{'loading.search-results' | translate}}"></ds-loading>
<ds-search-results-skeleton
*ngIf="isLoading()"
[showThumbnails]="showThumbnails"
[numberOfResults]="searchConfig.pagination.pageSize"
></ds-search-results-skeleton>
<ds-error *ngIf="showError()"
message="{{errorMessageLabel() | translate}}"></ds-error>
<div *ngIf="searchResults?.payload?.page.length === 0 || searchResults?.statusCode === 400">

View File

@@ -0,0 +1,17 @@
:host ::ng-deep {
.filter-badge-skeleton {
ngx-skeleton-loader .skeleton-loader {
background-color: var(--bs-light);
box-shadow: none;
width: var(--ds-search-skeleton-filter-badge-width);
height: var(--ds-search-skeleton-badge-height);
margin-bottom: 0;
margin-right: calc(var(--bs-spacer) / 4);
}
}
.filters-badge-skeleton-container {
display: flex;
max-height: var(--ds-search-skeleton-badge-height);
}
}

View File

@@ -13,16 +13,20 @@ import { ActivatedRoute } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { Community } from '../../../core/shared/community.model';
import { SearchService } from '../../../core/shared/search/search.service';
import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service';
import { ErrorComponent } from '../../error/error.component';
import { ThemedLoadingComponent } from '../../loading/themed-loading.component';
import { getMockThemeService } from '../../mocks/theme-service.mock';
import { ObjectCollectionComponent } from '../../object-collection/object-collection.component';
import { createFailedRemoteDataObject } from '../../remote-data.utils';
import { ActivatedRouteStub } from '../../testing/active-router.stub';
import { QueryParamsDirectiveStub } from '../../testing/query-params-directive.stub';
import { SearchConfigurationServiceStub } from '../../testing/search-configuration-service.stub';
import { SearchServiceStub } from '../../testing/search-service.stub';
import { ThemeService } from '../../theme-support/theme.service';
import { SearchExportCsvComponent } from '../search-export-csv/search-export-csv.component';
import { SearchResultsComponent } from './search-results.component';
import { SearchResultsSkeletonComponent } from './search-results-skeleton/search-results-skeleton.component';
describe('SearchResultsComponent', () => {
let comp: SearchResultsComponent;
@@ -35,6 +39,11 @@ describe('SearchResultsComponent', () => {
providers: [
{ provide: ActivatedRoute, useValue: new ActivatedRouteStub() },
{ provide: ThemeService, useValue: getMockThemeService() },
{ provide: SearchService, useValue: new SearchServiceStub() },
{
provide: SearchConfigurationService,
useValue: new SearchConfigurationServiceStub(),
},
],
imports: [
TranslateModule.forRoot(),
@@ -48,8 +57,8 @@ describe('SearchResultsComponent', () => {
imports: [
SearchExportCsvComponent,
ObjectCollectionComponent,
ThemedLoadingComponent,
ErrorComponent,
SearchResultsSkeletonComponent,
],
},
add: { imports: [QueryParamsDirectiveStub] },
@@ -96,7 +105,7 @@ describe('SearchResultsComponent', () => {
it('should display link with new search where query is quoted if search return a error 400', () => {
(comp as any).searchResults = createFailedRemoteDataObject('Error', 400);
(comp as any).searchConfig = { query: 'foobar' };
(comp as any).searchConfig = { query: 'foobar', pagination: { pageSize: 10 } };
fixture.detectChanges();
const linkDes = fixture.debugElement.queryAll(By.directive(QueryParamsDirectiveStub));

View File

@@ -1,4 +1,7 @@
import { NgIf } from '@angular/common';
import {
AsyncPipe,
NgIf,
} from '@angular/common';
import {
Component,
EventEmitter,
@@ -7,12 +10,19 @@ import {
} from '@angular/core';
import { RouterLink } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import {
BehaviorSubject,
Observable,
} from 'rxjs';
import { SortOptions } from '../../../core/cache/models/sort-options.model';
import { PaginatedList } from '../../../core/data/paginated-list.model';
import { RemoteData } from '../../../core/data/remote-data';
import { Context } from '../../../core/shared/context.model';
import { DSpaceObject } from '../../../core/shared/dspace-object.model';
import { SearchService } from '../../../core/shared/search/search.service';
import { SearchConfigurationService } from '../../../core/shared/search/search-configuration.service';
import { ViewMode } from '../../../core/shared/view-mode.model';
import {
fadeIn,
@@ -23,13 +33,15 @@ import {
isNotEmpty,
} from '../../empty.util';
import { ErrorComponent } from '../../error/error.component';
import { ThemedLoadingComponent } from '../../loading/themed-loading.component';
import { CollectionElementLinkType } from '../../object-collection/collection-element-link.type';
import { ObjectCollectionComponent } from '../../object-collection/object-collection.component';
import { ListableObject } from '../../object-collection/shared/listable-object.model';
import { AppliedFilter } from '../models/applied-filter.model';
import { PaginatedSearchOptions } from '../models/paginated-search-options.model';
import { SearchFilter } from '../models/search-filter.model';
import { SearchResult } from '../models/search-result.model';
import { SearchExportCsvComponent } from '../search-export-csv/search-export-csv.component';
import { SearchResultsSkeletonComponent } from './search-results-skeleton/search-results-skeleton.component';
export interface SelectionConfig {
repeatable: boolean;
@@ -39,12 +51,13 @@ export interface SelectionConfig {
@Component({
selector: 'ds-base-search-results',
templateUrl: './search-results.component.html',
styleUrls: ['./search-results.component.scss'],
animations: [
fadeIn,
fadeInOut,
],
standalone: true,
imports: [NgIf, SearchExportCsvComponent, ObjectCollectionComponent, ThemedLoadingComponent, ErrorComponent, RouterLink, TranslateModule],
imports: [NgIf, SearchExportCsvComponent, ObjectCollectionComponent, ErrorComponent, RouterLink, TranslateModule, SearchResultsSkeletonComponent, AsyncPipe, NgxSkeletonLoaderModule],
})
/**
@@ -52,6 +65,15 @@ export interface SelectionConfig {
*/
export class SearchResultsComponent {
hasNoValue = hasNoValue;
/**
* Currently active filters in url
*/
activeFilters$: Observable<SearchFilter[]>;
/**
* Filter applied to show labels, once populated the activeFilters$ will be loaded
*/
appliedFilters$: BehaviorSubject<AppliedFilter[]>;
/**
* The link type of the listed search results
@@ -125,10 +147,18 @@ export class SearchResultsComponent {
@Output() selectObject: EventEmitter<ListableObject> = new EventEmitter<ListableObject>();
constructor(
protected searchConfigService: SearchConfigurationService,
protected searchService: SearchService,
) {
this.activeFilters$ = this.searchConfigService.getCurrentFilters();
this.appliedFilters$ = this.searchService.appliedFilters$;
}
/**
* Check if search results are loading
*/
isLoading() {
isLoading(): boolean {
return !this.showError() && (hasNoValue(this.searchResults) || hasNoValue(this.searchResults.payload) || this.searchResults.isLoading);
}

View File

@@ -1,6 +1,7 @@
import {
ChangeDetectionStrategy,
NO_ERRORS_SCHEMA,
PLATFORM_ID,
} from '@angular/core';
import {
ComponentFixture,
@@ -246,6 +247,7 @@ export function configureSearchComponentTestingModule(compType, additionalDeclar
},
{ provide: APP_DATA_SERVICES_MAP, useValue: {} },
{ provide: APP_CONFIG, useValue: environment },
{ provide: PLATFORM_ID, useValue: 'browser' },
],
schemas: [NO_ERRORS_SCHEMA],
}).overrideComponent(compType, {
@@ -415,5 +417,34 @@ describe('SearchComponent', () => {
expect(result).toBeNull();
});
});
describe('when rendered in SSR', () => {
beforeEach(() => {
comp.platformId = 'server';
});
it('should not call search method on init', (done) => {
comp.ngOnInit();
//Check that the first method from which the search depend upon is not being called
expect(searchConfigurationServiceStub.getCurrentConfiguration).not.toHaveBeenCalled();
comp.initialized$.subscribe((res) => {
expect(res).toBeTruthy();
done();
});
});
});
describe('when rendered in CSR', () => {
beforeEach(() => {
comp.platformId = 'browser';
});
it('should call search method on init', fakeAsync(() => {
comp.ngOnInit();
tick(100);
//Check that the last method from which the search depend upon is being called
expect(searchServiceStub.search).toHaveBeenCalled();
}));
});
});
});

View File

@@ -1,5 +1,6 @@
import {
AsyncPipe,
isPlatformServer,
NgIf,
NgTemplateOutlet,
} from '@angular/common';
@@ -12,6 +13,7 @@ import {
OnDestroy,
OnInit,
Output,
PLATFORM_ID,
} from '@angular/core';
import {
NavigationStart,
@@ -37,6 +39,7 @@ import {
APP_CONFIG,
AppConfig,
} from '../../../config/app-config.interface';
import { environment } from '../../../environments/environment';
import { COLLECTION_MODULE_PATH } from '../../collection-page/collection-page-routing-paths';
import { COMMUNITY_MODULE_PATH } from '../../community-page/community-page-routing-paths';
import { SortOptions } from '../../core/cache/models/sort-options.model';
@@ -236,6 +239,11 @@ export class SearchComponent implements OnDestroy, OnInit {
*/
@Input() hideScopeInUrl: boolean;
/**
* Defines whether to fetch search results during SSR execution
*/
@Input() renderOnServerSide: boolean;
/**
* The current configuration used during the search
*/
@@ -251,6 +259,7 @@ export class SearchComponent implements OnDestroy, OnInit {
*/
currentScope$: Observable<string>;
/**
* The current sort options used
*/
@@ -345,6 +354,7 @@ export class SearchComponent implements OnDestroy, OnInit {
protected routeService: RouteService,
protected router: Router,
@Inject(APP_CONFIG) protected appConfig: AppConfig,
@Inject(PLATFORM_ID) public platformId: any,
) {
this.isXsOrSm$ = this.windowService.isXsOrSm();
}
@@ -357,6 +367,14 @@ export class SearchComponent implements OnDestroy, OnInit {
* If something changes, update the list of scopes for the dropdown
*/
ngOnInit(): void {
if (!this.renderOnServerSide && !environment.ssr.enableSearchComponent && isPlatformServer(this.platformId)) {
this.subs.push(this.getSearchOptions().pipe(distinctUntilChanged()).subscribe((options) => {
this.searchOptions$.next(options);
}));
this.initialized$.next(true);
return;
}
if (this.useUniquePageId) {
// Create an unique pagination id related to the instance of the SearchComponent
this.paginationId = uniqueId(this.paginationId);

View File

@@ -26,6 +26,10 @@ export class SearchConfigurationServiceStub {
return observableOf([]);
}
getCurrentFilters() {
return observableOf([]);
}
getCurrentScope(a) {
return observableOf('test-id');
}

View File

@@ -516,6 +516,7 @@ export class DefaultAppConfig implements AppConfig {
enabled: false,
filter: ['title', 'author', 'subject', 'entityType'],
},
filterPlaceholdersCount: 5,
};
notifyMetrics: AdminNotifyMetricsRow[] = [

View File

@@ -8,5 +8,11 @@ export interface SearchConfig extends Config {
* Used by {@link UploadBitstreamComponent}.
*/
advancedFilters: AdvancedSearchConfig;
/**
* Number used to render n UI elements called loading skeletons that act as placeholders.
* These elements indicate that some content will be loaded in their stead.
* Since we don't know how many filters will be loaded before we receive a response from the server we use this parameter for the skeletons count.
* For instance if we set 5 then 5 loading skeletons will be visualized before the actual filters are retrieved.
*/
filterPlaceholdersCount?: number;
}

View File

@@ -25,4 +25,14 @@ export interface SSRConfig extends Config {
* Paths to enable SSR for. Defaults to the home page and paths in the sitemap.
*/
paths: Array<string>;
/**
* Whether to enable rendering of search component on SSR
*/
enableSearchComponent: boolean;
/**
* Whether to enable rendering of browse component on SSR
*/
enableBrowseComponent: boolean;
}

View File

@@ -9,5 +9,7 @@ export const environment: Partial<BuildConfig> = {
enablePerformanceProfiler: false,
inlineCriticalCss: false,
paths: [ '/home', '/items/', '/entities/', '/collections/', '/communities/', '/bitstream/', '/bitstreams/', '/handle/' ],
enableSearchComponent: false,
enableBrowseComponent: false,
},
};

View File

@@ -13,6 +13,8 @@ export const environment: BuildConfig = {
enablePerformanceProfiler: false,
inlineCriticalCss: false,
paths: [ '/home', '/items/', '/entities/', '/collections/', '/communities/', '/bitstream/', '/bitstreams/', '/handle/' ],
enableSearchComponent: false,
enableBrowseComponent: false,
},
// Angular express server settings.

View File

@@ -14,6 +14,8 @@ export const environment: Partial<BuildConfig> = {
enablePerformanceProfiler: false,
inlineCriticalCss: false,
paths: [ '/home', '/items/', '/entities/', '/collections/', '/communities/', '/bitstream/', '/bitstreams/', '/handle/' ],
enableSearchComponent: false,
enableBrowseComponent: false,
},
};

View File

@@ -150,4 +150,16 @@
--green1: #1c710a; // This variable represents the success color for the Orejime cookie banner
--button-text-color-cookie: #fff; // This variable represents the text color for buttons in the Orejime cookie banner
--ds-search-skeleton-text-height: 20px;
--ds-search-skeleton-badge-height: 18px;
--ds-search-skeleton-thumbnail-margin: 1em;
--ds-search-skeleton-text-line-count: 2;
--ds-search-skeleton-badge-width: 75px;
--ds-search-skeleton-filter-badge-width: 200px;
--ds-search-skeleton-info-width: 200px;
--ds-search-skeleton-card-height: 435px;
--ds-filters-skeleton-height: 40px;
--ds-filters-skeleton-spacing: 12px;
}

View File

@@ -5,18 +5,14 @@
*
* https://www.atmire.com/software-license/
*/
import {
AsyncPipe,
NgFor,
NgIf,
} from '@angular/common';
import { AsyncPipe } from '@angular/common';
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { SearchConfigurationService } from '../../../../../../app/core/shared/search/search-configuration.service';
import { SEARCH_CONFIG_SERVICE } from '../../../../../../app/my-dspace-page/my-dspace-configuration.service';
import { AdvancedSearchComponent } from '../../../../../../app/shared/search/advanced-search/advanced-search.component';
import { SearchFilterComponent } from '../../../../../../app/shared/search/search-filters/search-filter/search-filter.component';
import { SearchFiltersComponent as BaseComponent } from '../../../../../../app/shared/search/search-filters/search-filters.component';
@@ -34,7 +30,7 @@ import { SearchFiltersComponent as BaseComponent } from '../../../../../../app/s
},
],
standalone: true,
imports: [NgIf, NgFor, SearchFilterComponent, RouterLink, AsyncPipe, TranslateModule, AdvancedSearchComponent],
imports: [SearchFilterComponent, RouterLink, AsyncPipe, TranslateModule, NgxSkeletonLoaderModule],
})
export class SearchFiltersComponent extends BaseComponent {

View File

@@ -1,29 +1,33 @@
import { NgIf } from '@angular/common';
import {
AsyncPipe,
NgIf,
} from '@angular/common';
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import {
fadeIn,
fadeInOut,
} from '../../../../../../app/shared/animations/fade';
import { ErrorComponent } from '../../../../../../app/shared/error/error.component';
import { ThemedLoadingComponent } from '../../../../../../app/shared/loading/themed-loading.component';
import { ObjectCollectionComponent } from '../../../../../../app/shared/object-collection/object-collection.component';
import { SearchExportCsvComponent } from '../../../../../../app/shared/search/search-export-csv/search-export-csv.component';
import { SearchResultsComponent as BaseComponent } from '../../../../../../app/shared/search/search-results/search-results.component';
import { SearchResultsSkeletonComponent } from '../../../../../../app/shared/search/search-results/search-results-skeleton/search-results-skeleton.component';
@Component({
selector: 'ds-themed-search-results',
// templateUrl: './search-results.component.html',
templateUrl: '../../../../../../app/shared/search/search-results/search-results.component.html',
// styleUrls: ['./search-results.component.scss'],
styleUrls: ['../../../../../../app/shared/search/search-results/search-results.component.scss'],
animations: [
fadeIn,
fadeInOut,
],
standalone: true,
imports: [NgIf, SearchExportCsvComponent, ObjectCollectionComponent, ThemedLoadingComponent, ErrorComponent, RouterLink, TranslateModule],
imports: [NgIf, SearchExportCsvComponent, ObjectCollectionComponent, ErrorComponent, RouterLink, TranslateModule, SearchResultsSkeletonComponent, SearchResultsSkeletonComponent, AsyncPipe, NgxSkeletonLoaderModule],
})
export class SearchResultsComponent extends BaseComponent {

View File

@@ -12,6 +12,7 @@ import { TranslateModule } from '@ngx-translate/core';
import { ScrollToModule } from '@nicky-lenaers/ngx-scroll-to';
import { RootModule } from '../../app/root.module';
import { SearchResultsSkeletonComponent } from '../../app/shared/search/search-results/search-results-skeleton/search-results-skeleton.component';
import { MetadataImportPageComponent } from './app/admin/admin-import-metadata-page/metadata-import-page.component';
import { AdminSearchPageComponent } from './app/admin/admin-search-page/admin-search-page.component';
import { AdminSidebarComponent } from './app/admin/admin-sidebar/admin-sidebar.component';
@@ -105,6 +106,7 @@ import { WorkflowItemDeleteComponent } from './app/workflowitems-edit-page/workf
import { WorkflowItemSendBackComponent } from './app/workflowitems-edit-page/workflow-item-send-back/workflow-item-send-back.component';
import { WorkspaceItemsDeletePageComponent } from './app/workspaceitems-edit-page/workspaceitems-delete-page/workspaceitems-delete-page.component';
const DECLARATIONS = [
FileSectionComponent,
HomePageComponent,
@@ -198,6 +200,7 @@ const DECLARATIONS = [
ComcolPageContentComponent,
AdminSearchPageComponent,
AdminWorkflowPageComponent,
SearchResultsSkeletonComponent,
];
@NgModule({

View File

@@ -18,6 +18,7 @@
/* set the next two properties as `--ds-header-navbar-border-bottom-*`
in order to keep the bottom border of the header when navbar is expanded */
--ds-expandable-navbar-border-top-color: #{$white};
--ds-expandable-navbar-border-top-height: 0;
--ds-expandable-navbar-padding-top: 0;