From 68f7d2445c9b90152a4e1b3da2f63041b3edba78 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Mon, 29 Oct 2018 15:53:39 +0100 Subject: [PATCH] feat(stark-ui): implement generic search component in generic module Fix potential issue in ActionBar component by adding `type="button"` attribute in every button. ISSUES CLOSED: #794 --- docs/GENERIC_SEARCH.md | 522 ++++++++++++++++++ packages/rollup.config.common-data.js | 1 + packages/stark-ui/assets/stark-ui-bundle.scss | 1 + packages/stark-ui/src/modules.ts | 1 + .../components/action-bar.component.html | 23 +- .../components/dropdown.component.spec.ts | 2 +- .../stark-ui/src/modules/generic-search.ts | 4 + .../src/modules/generic-search/classes.ts | 4 + .../classes/abstract-form-component.ts | 42 ++ .../classes/abstract-search-component.spec.ts | 437 +++++++++++++++ .../classes/abstract-search-component.ts | 196 +++++++ .../classes/generic-search.service.intf.ts | 30 + .../classes/search-form-component.intf.ts | 11 + .../src/modules/generic-search/components.ts | 1 + .../components/generic-search.ts | 1 + .../_generic-search.component.scss | 33 ++ .../generic-search.component.html | 85 +++ .../generic-search.component.ts | 517 +++++++++++++++++ .../src/modules/generic-search/entities.ts | 4 + .../entities/form-action.intf.ts | 78 +++ .../generic-search-action-bar-config.intf.ts | 21 + ...generic-search-form-buttons-config.intf.ts | 26 + .../entities/search-state.entity.intf.ts | 14 + .../generic-search/generic-search.module.ts | 27 + .../stark-ui/src/util/form/form.util.spec.ts | 20 +- showcase/src/app/app-menu.config.ts | 7 + showcase/src/app/app.module.ts | 6 + showcase/src/app/demo-ui/demo-ui.module.ts | 31 +- .../actions/demo-generic-search.actions.ts | 49 ++ .../pages/generic-search/actions/index.ts | 1 + .../demo-generic-search-form.component.html | 48 ++ .../demo-generic-search-form.component.ts | 78 +++ .../pages/generic-search/components/index.ts | 1 + .../demo-generic-search-page.component.html | 36 ++ .../demo-generic-search-page.component.ts | 95 ++++ .../entities/hero-movie-search.entity.ts | 9 + .../entities/hero-movie.entity.ts | 16 + .../pages/generic-search/entities/index.ts | 2 + .../app/demo-ui/pages/generic-search/index.ts | 6 + .../reducers/demo-generic-search.reducer.ts | 26 + .../pages/generic-search/reducers/index.ts | 18 + .../services/demo-generic.service.ts | 140 +++++ .../pages/generic-search/services/index.ts | 1 + showcase/src/app/demo-ui/pages/index.ts | 1 + showcase/src/app/demo-ui/routes.ts | 6 + .../reference-block.component.html | 2 +- .../reference-block.component.spec.ts | 3 +- .../demo-generic-search-page.component.html | 20 + .../demo-generic-search-page.component.ts | 81 +++ showcase/src/assets/translations/en.json | 12 + showcase/src/assets/translations/fr.json | 12 + showcase/src/assets/translations/nl.json | 12 + 52 files changed, 2799 insertions(+), 21 deletions(-) create mode 100644 docs/GENERIC_SEARCH.md create mode 100644 packages/stark-ui/src/modules/generic-search.ts create mode 100644 packages/stark-ui/src/modules/generic-search/classes.ts create mode 100644 packages/stark-ui/src/modules/generic-search/classes/abstract-form-component.ts create mode 100644 packages/stark-ui/src/modules/generic-search/classes/abstract-search-component.spec.ts create mode 100644 packages/stark-ui/src/modules/generic-search/classes/abstract-search-component.ts create mode 100644 packages/stark-ui/src/modules/generic-search/classes/generic-search.service.intf.ts create mode 100644 packages/stark-ui/src/modules/generic-search/classes/search-form-component.intf.ts create mode 100644 packages/stark-ui/src/modules/generic-search/components.ts create mode 100644 packages/stark-ui/src/modules/generic-search/components/generic-search.ts create mode 100644 packages/stark-ui/src/modules/generic-search/components/generic-search/_generic-search.component.scss create mode 100644 packages/stark-ui/src/modules/generic-search/components/generic-search/generic-search.component.html create mode 100644 packages/stark-ui/src/modules/generic-search/components/generic-search/generic-search.component.ts create mode 100644 packages/stark-ui/src/modules/generic-search/entities.ts create mode 100644 packages/stark-ui/src/modules/generic-search/entities/form-action.intf.ts create mode 100644 packages/stark-ui/src/modules/generic-search/entities/generic-search-action-bar-config.intf.ts create mode 100644 packages/stark-ui/src/modules/generic-search/entities/generic-search-form-buttons-config.intf.ts create mode 100644 packages/stark-ui/src/modules/generic-search/entities/search-state.entity.intf.ts create mode 100644 packages/stark-ui/src/modules/generic-search/generic-search.module.ts create mode 100644 showcase/src/app/demo-ui/pages/generic-search/actions/demo-generic-search.actions.ts create mode 100644 showcase/src/app/demo-ui/pages/generic-search/actions/index.ts create mode 100644 showcase/src/app/demo-ui/pages/generic-search/components/demo-generic-search-form.component.html create mode 100644 showcase/src/app/demo-ui/pages/generic-search/components/demo-generic-search-form.component.ts create mode 100644 showcase/src/app/demo-ui/pages/generic-search/components/index.ts create mode 100644 showcase/src/app/demo-ui/pages/generic-search/demo-generic-search-page.component.html create mode 100644 showcase/src/app/demo-ui/pages/generic-search/demo-generic-search-page.component.ts create mode 100644 showcase/src/app/demo-ui/pages/generic-search/entities/hero-movie-search.entity.ts create mode 100644 showcase/src/app/demo-ui/pages/generic-search/entities/hero-movie.entity.ts create mode 100644 showcase/src/app/demo-ui/pages/generic-search/entities/index.ts create mode 100644 showcase/src/app/demo-ui/pages/generic-search/index.ts create mode 100644 showcase/src/app/demo-ui/pages/generic-search/reducers/demo-generic-search.reducer.ts create mode 100644 showcase/src/app/demo-ui/pages/generic-search/reducers/index.ts create mode 100644 showcase/src/app/demo-ui/pages/generic-search/services/demo-generic.service.ts create mode 100644 showcase/src/app/demo-ui/pages/generic-search/services/index.ts create mode 100644 showcase/src/assets/examples/generic-search/demo-generic-search-page.component.html create mode 100644 showcase/src/assets/examples/generic-search/demo-generic-search-page.component.ts diff --git a/docs/GENERIC_SEARCH.md b/docs/GENERIC_SEARCH.md new file mode 100644 index 0000000000..8b0906709c --- /dev/null +++ b/docs/GENERIC_SEARCH.md @@ -0,0 +1,522 @@ +# Stark Generic Search component + +## 1. About + +This component provides a generic way to handle search flow in your application. +You only need to provide this component with your custom search form via transclusion and the necessary methods to perform the search. +Finally this component will return the results in an Observable that you can subscribe to. + +## 2. How to use it + +### 2.1. Create an entitity for the search (optional) + +For using the Generic Search component, you must have an entity that describes your object. For some reasons, the entity which describes the search form could be different than the object and in this case, it is necessary to create a different entity to describe this search object. + +This HowTo presents the usage of the Generic Search component with the search entity. Obviously, if you don't need a specific entity for the search, you could just replace the "Search entity" by the entity of your object in the code of the controllers, reducers and services that follow. + +#### 2.1.1. Create the entity for your object + +For the following example, we need this entity: + +```typescript +import { StarkResource } from "@nationalbankbelgium/stark-core"; +import { autoserialize } from "cerialize"; + +export class MovieObject implements StarkResource { + @autoserialize + public uuid: string; + + @autoserialize + public year: number; + + @autoserialize + public hero: string; + + @autoserialize + public title: string; +} +``` + +#### 2.1.2. Create the search entity file + +Under the src/app/modules//entities folder create the _type-data_-**search.entity.ts** file. +This entity contains all the fields that the search form will contain to find a specific element in the concerned collection. + +```typescript +export class MovieSearchCriteria { + public year?: string; + public hero?: string; + public title?: string; +} +``` + +### 2.2. Create a form component + +Generate your form component in _src/app/modules//components_ by using **ng cli**: + +```bash +ng generate component src/app/modules/my_module/components/my_component-search-form +``` + +#### 2.2.1. Define your component + +The component must only have one **input** binding, the object which describes the content of the form. +And the component have the **output** binding "workingCopyChanged" to emit a new value of the search criteria when they change. + +```typescript +import { Component, EventEmitter, Inject, Input, OnInit, Output } from "@angular/core"; +import { StarkSearchFormComponent } from "@nationalbankbelgium/stark-ui"; +import { GenericObjectSearchCriteria } from "../entities"; +import { FormControl, FormGroup } from "@angular/forms"; +import { DEMO_GENERIC_SERVICE, DemoGenericService } from "../services"; + +@Component({ + selector: "movie-search-form", + templateUrl: "./movie-search-form.component.html" +}) +export class MovieSearchFormComponent implements OnInit, StarkSearchFormComponent { + @Input() + public searchCriteria: MovieSearchCriteria = {}; + + @Output() + public workingCopyChanged: EventEmitter = new EventEmitter(); + + public yearOptions: number[] = []; + public heroOptions: string[] = []; + public movieOptions: string[] = []; + + public searchForm: FormGroup; + + public constructor(@Inject(DEMO_GENERIC_SERVICE) private genericService: DemoGenericService) {} + + public ngOnInit(): void { + this.searchForm = new FormGroup({ + year: new FormControl(this.searchCriteria.year), + hero: new FormControl(this.searchCriteria.hero), + title: new FormControl(this.searchCriteria.movie) + }); + + this.searchForm.valueChanges.subscribe(() => { + const modifiedCriteria: MovieSearchCriteria = this.mapFormGroupToSearchCriteria(this.searchForm); + this.workingCopyChanged.emit(modifiedCriteria); + }); + + // ... + } + + public mapFormGroupToSearchCriteria(formGroup: FormGroup): MovieSearchCriteria { + // return formGroup.getRawValue(); + + return { + year: formGroup.controls["year"].value, + hero: formGroup.controls["hero"].value, + title: formGroup.controls["title"].value + }; + } + + /** + * @ignore + */ + public trackItemFn(item: string): string { + return item; + } +} +``` + +#### 2.2.2. Define your template + +```html +
+ +
+ + + + + {{ option }} + +
+ +
+ + + + + {{ option }} + +
+ +
+ + + + + {{ option }} + +
+
+``` + +### 2.3. Create the search actions + +#### 2.3.1. Create the following files + +Under the `src/app/modules//actions` folder create the files **movies-search.actions.ts** and **index.ts**. + +Obviously, you'll have to export everything from your _actions.ts_ file in your barrel (index.ts). + +#### 2.3.2. Define the actions + +The following actions should be defined to make your GenericSearch working perfectly. + +```typescript +import { Action } from "@ngrx/store"; +import { MovieSearchCriteria } from "../entities"; + +export enum MovieActionTypes { + SET_MOVIE_SEARCH_CRITERIA = "[MovieSearch] Set criteria", + REMOVE_MOVIE_SEARCH_CRITERIA = "[MovieSearch] Remove criteria", + MOVIE_HAS_SEARCHED = "[MovieSearch] Has searched", + MOVIE_HAS_SEARCHED_RESET = "[MovieSearch] Has searched reset" +} + +export class MovieSearchSetCriteria implements Action { + /** + * The type of action + */ + public readonly type: MovieActionTypes.SET_MOVIE_SEARCH_CRITERIA = MovieActionTypes.SET_MOVIE_SEARCH_CRITERIA; + /** + * Class constructor + * @param criteria - Criteria to be set + */ + public constructor(public criteria: MovieSearchCriteria) {} +} + +export class MovieSearchRemoveCriteria implements Action { + /** + * The type of action + */ + public readonly type: MovieActionTypes.REMOVE_MOVIE_SEARCH_CRITERIA = MovieActionTypes.REMOVE_MOVIE_SEARCH_CRITERIA; +} + +export class MovieSearchHasSearched implements Action { + /** + * The type of action + */ + public readonly type: MovieActionTypes.MOVIE_HAS_SEARCHED = MovieActionTypes.MOVIE_HAS_SEARCHED; +} + +export class MovieSearchHasSearchedReset implements Action { + /** + * The type of action + */ + public readonly type: MovieActionTypes.MOVIE_HAS_SEARCHED_RESET = MovieActionTypes.MOVIE_HAS_SEARCHED_RESET; +} + +export type MovieSearchActions = MovieSearchRemoveCriteria | MovieSearchHasSearchedReset | MovieSearchHasSearched | MovieSearchSetCriteria; +``` + +### 2.4. Create the search reducers + +#### 2.4.1. Create the following files + +Under the `src/app/modules//reducers` folder create the files **movie-search.reducer.ts** and **index.ts**. + +#### 2.4.2. Define the search reducer + +The reducer must contain the following options. + +Don't forget to rename every variable and function that contain "movies" with your used type. + +```typescript +import { StarkSearchState } from "@nationalbankbelgium/stark-ui"; +import { MovieSearchCriteria } from "../entities/movies-search.entity"; +import { MovieActionTypes, MovieSearchActions } from "../actions"; + +const INITIAL_STATE: Readonly> = { + criteria: new MovieSearchCriteria(), + hasBeenSearched: false +}; + +export function demoGenericSearchReducer( + state: Readonly> = INITIAL_STATE, + action: Readonly +): Readonly> { + switch (action.type) { + case MovieActionTypes.SET_MOVIE_SEARCH_CRITERIA: + return { ...state, criteria: action.criteria }; + case MovieActionTypes.REMOVE_MOVIE_SEARCH_CRITERIA: + return { ...state, criteria: INITIAL_STATE.criteria }; + case MovieActionTypes.MOVIE_HAS_SEARCHED: + return { ...state, hasBeenSearched: true }; + case MovieActionTypes.MOVIE_HAS_SEARCHED_RESET: + return { ...state, hasBeenSearched: false }; + default: + return state; + } +} +``` + +#### 2.4.3. Define the search reducers index + +Copy the following snippet in your _reducers/index.ts_ file. + +Don't forget to rename every variable and function that contain "movies" with your used type. + +```typescript +import { StarkSearchState } from "@nationalbankbelgium/stark-ui"; +import { ActionReducerMap, createSelector, MemoizedSelector, createFeatureSelector } from "@ngrx/store"; +import { MovieSearchCriteria } from "../entities"; +import { MovieSearchActions } from "../actions"; +import { movieSearchReducer } from "./movie-search.reducer"; + +export interface MovieSearchState { + movieSearch: StarkSearchState; +} + +export const movieSearchReducers: ActionReducerMap = { + movieSearch: movieSearchReducer +}; + +export const selectMovieSearch: MemoizedSelector> = createSelector( + createFeatureSelector("MovieSearch"), + (state: MovieSearchState) => state.movieSearch +); +``` + +#### 2.4.4. Declare the reducers + +In your module file, add the following import to be able to use your reducers. + +```typescript +// imports + +import { StoreModule } from "@ngrx/store"; +import { movieSearchReducers } from "./reducers"; + +@NgModule({ + imports: [ + // ... + StoreModule.forFeature("MovieSearch", movieSearchReducers) + ] +}) +export class MyModule {} +``` + +### 2.5. Create the search service + +The search service is an implementation of the Stark Generic Search Service. + +It must contain these functions : + +- search +- getSearchState +- resetSearchState +- createNew + +The search service depends on the _type_.**repository.ts**. + +#### 2.5.1. Create the search service + +##### 2.5.1.1. Create the interface + +You need to create a Search service interface that extends the **StarkGenericSearchService** interface and pass the type of the data in the extension. +Firstly the main entity, **Movie** in this case, and secondly the search entity, **MovieSearch** still for this case. + +```typescript +import { MovieObject, MovieSearchCriteria } from "../entities"; +import { StarkGenericSearchService } from "@nationalbankbelgium/stark-ui"; +import { InjectionToken } from "@angular/core"; +import { Observable } from "rxjs"; + +export const movieServiceName: string = "DemoGenericService"; +export const MOVIE_SERVICE: InjectionToken = new InjectionToken(movieServiceName); + +export interface MovieService extends StarkGenericSearchService { + getYears(): Observable; + + getMovies(): Observable; + + getHeroes(): Observable; +} +``` + +##### 2.5.1.2. Create the implementation + +You need + +```typescript +import { MovieService } from "./demo-generic.service.intf"; +import { Inject, Injectable } from "@angular/core"; +import { Observable, of } from "rxjs"; +import { map } from "rxjs/operators"; +import { StarkSearchState } from "@nationalbankbelgium/stark-ui"; +import { MovieObject, MovieSearchCriteria } from "../entities"; +import { MovieSearchState, selectMovieSearch } from "../reducers"; +import { MOVIE_REPOSITORY, MovieRepository } from "../repositories"; +import { Store, select } from "@ngrx/store"; +import { MovieSearchHasSearched, MovieSearchHasSearchedReset, MovieSearchRemoveCriteria, MovieSearchSetCriteria } from "../actions"; + +@Injectable() +export class DemoGenericServiceImpl implements MovieService { + public constructor(private store: Store, @Inject(MOVIE_REPOSITORY) private movieRepository: MovieRepository) {} + + public getSearchState(): Observable> { + return this.store.pipe(select(selectMovieSearch)); + } + + public resetSearchState(): void { + this.store.dispatch(new MovieSearchRemoveCriteria()); + this.store.dispatch(new MovieSearchHasSearchedReset()); + } + + public search(criteria: MovieSearchCriteria): Observable { + this.store.dispatch(new MovieSearchSetCriteria(criteria)); + this.store.dispatch(new MovieSearchHasSearched()); + + return this.movieRepository.search(criteria); + } +} +``` + +Then don't forget to declare your service in your module :blush: + +### 2.6. Create the search component page + +Generate your search component page in _src/app/modules//pages_ by using **ng cli**: + +```bash +ng generate component src/app/modules/my_module/pages/my_component-search-page +``` + +#### 2.6.1. Define your component + +The component extends the **AbstractStarkSearchComponent** and pass the type of the data in the extension. +Firstly the main entity, **Movie** in this case, and secondly the search entity, **MovieSearch** still for this case. +This component controller describes the different elements necessary for the Stark Table, as the StarkPaginationConfig and the StarkTableColumnProperties. + +Also, this component's controller must call the parent function ngOnInit in the ngOnInit function. + +```typescript +import { Component, Inject, OnDestroy, OnInit } from "@angular/core"; +import { STARK_LOGGING_SERVICE, StarkLoggingService } from "@nationalbankbelgium/stark-core"; +import { AbstractStarkSearchComponent, StarkPaginationConfig, StarkTableColumnProperties } from "@nationalbankbelgium/stark-ui"; +import { Movie, MovieSearchCriteria } from "./entities"; +import { MOVIE_SERVICE, MovieService } from "./services"; + +@Component({ + selector: "movie-search", + templateUrl: "./movie-search-page.component.html" +}) +export class MovieSearchPageComponent extends AbstractStarkSearchComponent implements OnInit, OnDestroy { + public columnsProperties: StarkTableColumnProperties[]; + public searchResults: Movie[]; + public paginationConfig: StarkPaginationConfig; + + public constructor( + @Inject(STARK_LOGGING_SERVICE) logger: StarkLoggingService, + @Inject(MOVIE_SERVICE) demoGenericService: MovieService + ) { + super(demoGenericService, logger); + + this.performSearchOnInit = true; // Turn on automatic search (last search criteria) + this.preserveLatestResults = true; // Keep a reference to the latest results in the latestResults variable + } + + /** + * Component lifecycle hook + */ + public ngOnInit(): void { + super.ngOnInit(); + + this.results$.subscribe((movies: Movie[]) => (this.searchResults = movies)); + + this.columnsProperties = [ + { + name: "hero", + label: "Hero", + isFilterable: true, + isSortable: true + }, + { + name: "title", + label: "Title", + isFilterable: true, + isSortable: true + }, + { + name: "year", + label: "Year", + isFilterable: true, + isSortable: true + } + ]; + + this.paginationConfig = { + isExtended: false, + itemsPerPage: 10, + itemsPerPageOptions: [10, 20, 50], + itemsPerPageIsPresent: true, + page: 1, + pageNavIsPresent: true, + pageInputIsPresent: true + }; + } + + /** + * Component lifecycle hook + */ + public ngOnDestroy(): void { + super.ngOnDestroy(); + } +} +``` + +#### 2.6.2. Define your template + +The template contains all information necessary for a page, the title and the stark-generic-search component. +The usage of Generic Search component is explained in the [API documentation](https://stark.nbb.be/api-docs/stark-ui/latest/components/StarkGenericSearchComponent.html). + +```html +

Movie Search

+
+ + + + +
+``` diff --git a/packages/rollup.config.common-data.js b/packages/rollup.config.common-data.js index ca98fc0bfc..c08f1e3122 100644 --- a/packages/rollup.config.common-data.js +++ b/packages/rollup.config.common-data.js @@ -8,6 +8,7 @@ const sourcemaps = require("rollup-plugin-sourcemaps"); const globals = { "@angularclass/hmr": "angularclass.hmr", + "@angular/animations": "ng.animations", "@angular/cdk": "ng.cdk", "@angular/cdk/collections": "ng.cdk.collections", "@angular/cdk/layout": "ng.cdk.layout", diff --git a/packages/stark-ui/assets/stark-ui-bundle.scss b/packages/stark-ui/assets/stark-ui-bundle.scss index cbf293a792..095e9b817e 100644 --- a/packages/stark-ui/assets/stark-ui-bundle.scss +++ b/packages/stark-ui/assets/stark-ui-bundle.scss @@ -25,6 +25,7 @@ @import "../src/modules/collapsible/components/collapsible.component"; @import "../src/modules/collapsible/components/collapsible-theme"; @import "../src/modules/date-range-picker/components/date-range-picker.component"; +@import "../src/modules/generic-search/components/generic-search/generic-search.component"; @import "../src/modules/language-selector/components/language-selector.component"; @import "../src/modules/message-pane/components/message-pane.component"; @import "../src/modules/message-pane/components/message-pane-theme"; diff --git a/packages/stark-ui/src/modules.ts b/packages/stark-ui/src/modules.ts index 70729a8950..758e639e86 100644 --- a/packages/stark-ui/src/modules.ts +++ b/packages/stark-ui/src/modules.ts @@ -10,6 +10,7 @@ export * from "./modules/collapsible"; export * from "./modules/date-picker"; export * from "./modules/date-range-picker"; export * from "./modules/dropdown"; +export * from "./modules/generic-search"; export * from "./modules/keyboard-directives"; export * from "./modules/language-selector"; export * from "./modules/message-pane"; diff --git a/packages/stark-ui/src/modules/action-bar/components/action-bar.component.html b/packages/stark-ui/src/modules/action-bar/components/action-bar.component.html index 97d75403da..c4728ef100 100644 --- a/packages/stark-ui/src/modules/action-bar/components/action-bar.component.html +++ b/packages/stark-ui/src/modules/action-bar/components/action-bar.component.html @@ -18,6 +18,7 @@ [matTooltip]="action.labelSwitchFunction ? (action.labelActivated | translate) : (action.label | translate)" [disabled]="!action.isEnabled" mat-icon-button + type="button" > - {{ action.label }} + {{ action.label | translate }} +
- - diff --git a/packages/stark-ui/src/modules/dropdown/components/dropdown.component.spec.ts b/packages/stark-ui/src/modules/dropdown/components/dropdown.component.spec.ts index a3c3916fc8..28273c9062 100644 --- a/packages/stark-ui/src/modules/dropdown/components/dropdown.component.spec.ts +++ b/packages/stark-ui/src/modules/dropdown/components/dropdown.component.spec.ts @@ -26,7 +26,7 @@ import Spy = jasmine.Spy; [options]="options" [placeholder]="placeholder" [required]="required" - (selectionChanged)="(selectionChanged)" + (selectionChanged)="selectionChanged($event)" [value]="value" > diff --git a/packages/stark-ui/src/modules/generic-search.ts b/packages/stark-ui/src/modules/generic-search.ts new file mode 100644 index 0000000000..dd3778c1ad --- /dev/null +++ b/packages/stark-ui/src/modules/generic-search.ts @@ -0,0 +1,4 @@ +export * from "./generic-search/components"; +export * from "./generic-search/classes"; +export * from "./generic-search/entities"; +export * from "./generic-search/generic-search.module"; diff --git a/packages/stark-ui/src/modules/generic-search/classes.ts b/packages/stark-ui/src/modules/generic-search/classes.ts new file mode 100644 index 0000000000..f904b5245a --- /dev/null +++ b/packages/stark-ui/src/modules/generic-search/classes.ts @@ -0,0 +1,4 @@ +export * from "./classes/abstract-form-component"; +export * from "./classes/abstract-search-component"; +export * from "./classes/generic-search.service.intf"; +export * from "./classes/search-form-component.intf"; diff --git a/packages/stark-ui/src/modules/generic-search/classes/abstract-form-component.ts b/packages/stark-ui/src/modules/generic-search/classes/abstract-form-component.ts new file mode 100644 index 0000000000..b27c97df0f --- /dev/null +++ b/packages/stark-ui/src/modules/generic-search/classes/abstract-form-component.ts @@ -0,0 +1,42 @@ +import { StarkLoggingService } from "@nationalbankbelgium/stark-core"; + +/** + * @ignore + */ +const _cloneDeep: Function = require("lodash/cloneDeep"); + +/** + * @ignore + */ +const _isEqual: Function = require("lodash/isEqual"); + +export abstract class AbstractStarkFormComponent { + public originalCopy: CriteriaType; + public workingCopy: CriteriaType; + + protected constructor(public logger: StarkLoggingService) {} + + /** + * Set the form's original copy to the object passed as parameter (a deep cloned copy). Default: empty object ({}) + * @param originalCopy - The object to be set as the form's original copy + */ + protected setOriginalCopy(originalCopy: CriteriaType = {}): void { + this.originalCopy = originalCopy; + this.workingCopy = _cloneDeep(this.originalCopy); + } + + /** + * Revert the form's working copy back to the original copy (a deep clone copy) + */ + protected reset(): void { + this.workingCopy = _cloneDeep(this.originalCopy); + } + + /** + * Check whether the working copy is exactly the same as the original copy. + * Performs a deep comparison between the two objects to determine if they are equivalent. + */ + protected isDirty(): boolean { + return !_isEqual(this.workingCopy, this.originalCopy); + } +} diff --git a/packages/stark-ui/src/modules/generic-search/classes/abstract-search-component.spec.ts b/packages/stark-ui/src/modules/generic-search/classes/abstract-search-component.spec.ts new file mode 100644 index 0000000000..6dd2e2049b --- /dev/null +++ b/packages/stark-ui/src/modules/generic-search/classes/abstract-search-component.spec.ts @@ -0,0 +1,437 @@ +/* tslint:disable:completed-docs no-identical-functions */ +import { Observer, Observable, Subject, Subscriber, of, throwError } from "rxjs"; +import { AbstractStarkSearchComponent, StarkGenericSearchService } from "../classes"; +import { MockStarkLoggingService } from "@nationalbankbelgium/stark-core/testing"; +import Spy = jasmine.Spy; +import { StarkLoggingService, StarkResource } from "@nationalbankbelgium/stark-core"; +import { FormGroup } from "@angular/forms"; +import { StarkSearchState } from "../entities"; +import { StarkProgressIndicatorService } from "../../progress-indicator/services"; +import { MockStarkProgressIndicatorService } from "../../progress-indicator/testing"; + +/* tslint:disable:no-big-function */ +describe("AbstractSearchComponent", () => { + let component: SearchComponentHelper; + let genericSearchService: StarkGenericSearchService; + let mockLogger: StarkLoggingService; + + let mockProgressService: StarkProgressIndicatorService; + + let originalSearchCriteria: SearchCriteria; + let getSearchStateObsTeardown: Spy; + let searchObsTeardown: Spy; + let mockObserver: Observer; + + beforeEach(() => { + genericSearchService = jasmine.createSpyObj("genericSearchService", ["getSearchState", "resetSearchState", "createNew", "search"]); + mockLogger = new MockStarkLoggingService(); + + mockProgressService = new MockStarkProgressIndicatorService(); + + component = new SearchComponentHelper(genericSearchService, mockLogger, mockProgressService); + getSearchStateObsTeardown = jasmine.createSpy("getSearchStateObsTeardown"); + searchObsTeardown = jasmine.createSpy("searchObsTeardown"); + originalSearchCriteria = { uuid: "3" }; + (genericSearchService.getSearchState).and.returnValue( + createObservableOf>( + { + criteria: originalSearchCriteria, + hasBeenSearched: false + }, + getSearchStateObsTeardown + ) + ); + + mockObserver = jasmine.createSpyObj>("observerSpy", ["next", "error", "complete"]); + }); + describe("ngOnInit", () => { + it("should clone the searchCriteria as originalCopy and search again if hasBeenSearched is TRUE", () => { + const expectedResult: MockResource[] = [{ uuid: "1", name: "first" }, { uuid: "2", name: "second" }]; + + (genericSearchService.search).and.returnValue(of(expectedResult)); + (genericSearchService.getSearchState).and.returnValue( + of({ + criteria: originalSearchCriteria, + hasBeenSearched: true + }) + ); + + component.ngOnInit(); + + component.getResults().subscribe(mockObserver); + expect(mockObserver.next).toHaveBeenCalledTimes(1); + expect(mockObserver.next).toHaveBeenCalledWith(expectedResult); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + + expect(genericSearchService.search).toHaveBeenCalledTimes(1); + expect(component.getOriginalCopy()).toBe(originalSearchCriteria); + expect(component.getWorkingCopy()).toEqual(originalSearchCriteria); + expect(component.getOriginalCopy()).not.toBe(component.getWorkingCopy()); + }); + + it("should set the searchCriteria as original copy and DON'T search if hasBeenSearched is FALSE", () => { + (genericSearchService.getSearchState).and.returnValue( + of({ + criteria: originalSearchCriteria, + hasBeenSearched: false + }) + ); + + component.ngOnInit(); + + component.getResults().subscribe(mockObserver); + expect(mockObserver.next).toHaveBeenCalledTimes(1); + expect(mockObserver.next).toHaveBeenCalledWith([]); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + + expect(genericSearchService.search).not.toHaveBeenCalled(); + expect(component.getOriginalCopy()).toBe(originalSearchCriteria); + expect(component.getWorkingCopy()).toEqual(originalSearchCriteria); + expect(component.getOriginalCopy()).not.toBe(component.getWorkingCopy()); + }); + + it("should subscribe for changes of the search state and set it as original copy whenever a change is triggered", () => { + const searchState$: Subject> = new Subject(); + const mockCriteria1: SearchCriteria = { uuid: "dummy uuid" }; + const mockCriteria2: SearchCriteria = { uuid: "another uuid" }; + + (genericSearchService.search).and.returnValue(of("dummy search result")); + (genericSearchService.getSearchState).and.returnValue(searchState$); + + component.ngOnInit(); + + searchState$.next({ + criteria: mockCriteria1, + hasBeenSearched: true + }); + + expect(component.getOriginalCopy()).not.toEqual(originalSearchCriteria); + expect(component.getOriginalCopy()).toBe(mockCriteria1); + expect(component.getWorkingCopy()).toEqual(mockCriteria1); + expect(component.getOriginalCopy()).not.toBe(component.getWorkingCopy()); + + (mockObserver.next).calls.reset(); + searchState$.next({ + criteria: mockCriteria2, + hasBeenSearched: true + }); + + expect(component.getOriginalCopy()).not.toEqual(originalSearchCriteria); + expect(component.getOriginalCopy()).not.toEqual(mockCriteria1); + expect(component.getOriginalCopy()).toBe(mockCriteria2); + expect(component.getWorkingCopy()).toEqual(mockCriteria2); + expect(component.getOriginalCopy()).not.toBe(component.getWorkingCopy()); + + searchState$.complete(); + }); + }); + + describe("ngOnDestroy", () => { + it("should cancel the subscription of the searchState", () => { + (genericSearchService.getSearchState).and.returnValue( + createObservableOf>( + { + criteria: originalSearchCriteria, + hasBeenSearched: false + }, + getSearchStateObsTeardown + ) + ); + + component.ngOnInit(); + component.ngOnDestroy(); + + expect(getSearchStateObsTeardown).toHaveBeenCalledTimes(1); + }); + }); + + describe("onSearch", () => { + it("should call performSearch() passing the working copy if the form is valid", () => { + const formMock: FormGroup = { + controls: {}, + invalid: false + }; + + spyOn(component, "performSearch"); + component.ngOnInit(); + component.onSearch(formMock); + + expect(component.performSearch).toHaveBeenCalledTimes(1); + expect(component.performSearch).toHaveBeenCalledWith(component.getWorkingCopy()); + }); + + it("should NOT call performSearch() if the form NOT valid", () => { + const formMock: FormGroup = { + controls: {}, + invalid: true + }; + + spyOn(component, "performSearch"); + component.ngOnInit(); + component.onSearch(formMock); + + expect(component.performSearch).not.toHaveBeenCalled(); + }); + }); + + describe("onNew", () => { + it("should call the genericSearchService.createNew()", () => { + component.ngOnInit(); + component.onNew(); + + expect(genericSearchService.createNew).toHaveBeenCalledTimes(1); + }); + }); + + describe("onReset", () => { + it("should call the genericSearchService.resetSearchState() and clear the results$", () => { + const formMock: FormGroup = new FormGroup({}); + component.ngOnInit(); + component.onReset(formMock); + + component.getResults().subscribe(mockObserver); + + expect(mockObserver.next).toHaveBeenCalledTimes(1); + expect(mockObserver.next).toHaveBeenCalledWith([]); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + + expect(genericSearchService.resetSearchState).toHaveBeenCalledTimes(1); + }); + }); + + describe("performSearch", () => { + it("should call genericSearchService.search() passing the working copy if no searchCriteria defined", () => { + const expectedResult: MockResource[] = [{ uuid: "1", name: "first" }, { uuid: "2", name: "second" }]; + (genericSearchService.search).and.returnValue(createObservableOf(expectedResult, searchObsTeardown)); + + component.ngOnInit(); + component.performSearch(); + + component.getResults().subscribe(mockObserver); + expect(mockObserver.next).toHaveBeenCalledTimes(1); + expect(mockObserver.next).toHaveBeenCalledWith(expectedResult); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + + expect(searchObsTeardown).toHaveBeenCalledTimes(1); + expect(genericSearchService.search).toHaveBeenCalledTimes(1); + expect(genericSearchService.search).toHaveBeenCalledWith(component.getWorkingCopy()); + }); + + it("should call genericSearchService.search() passing the given searchCriteria", () => { + const expectedResult: MockResource[] = [{ uuid: "1", name: "first" }, { uuid: "2", name: "second" }]; + const customCriteria: SearchCriteria = { uuid: "11" }; + (genericSearchService.search).and.returnValue(createObservableOf(expectedResult, searchObsTeardown)); + + component.ngOnInit(); + component.performSearch(customCriteria); + + component.getResults().subscribe(mockObserver); + expect(mockObserver.next).toHaveBeenCalledTimes(1); + expect(mockObserver.next).toHaveBeenCalledWith(expectedResult); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + + expect(searchObsTeardown).toHaveBeenCalledTimes(1); + expect(genericSearchService.search).toHaveBeenCalledTimes(1); + expect(genericSearchService.search).toHaveBeenCalledWith(customCriteria); + }); + + it("should call genericSearchService.search() ONLY ONCE if no previous search has been made regardless of searchState changes", () => { + const searchState$: Subject> = new Subject(); + const pristineCriteria: SearchCriteria = { uuid: "" }; + + const expectedResult: MockResource[] = [{ uuid: "1", name: "first" }, { uuid: "2", name: "second" }]; + const customCriteria: SearchCriteria = { uuid: "11" }; + (genericSearchService.search).and.returnValue(createObservableOf(expectedResult, searchObsTeardown)); + (genericSearchService.getSearchState).and.returnValue(searchState$); + + component.ngOnInit(); + + // initial search state => hasBeenSearched = false + searchState$.next({ + criteria: pristineCriteria, + hasBeenSearched: false + }); + + component.getResults().subscribe(mockObserver); + expect(mockObserver.next).toHaveBeenCalledTimes(1); + expect(mockObserver.next).toHaveBeenCalledWith([]); // initial value + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + + expect(genericSearchService.search).not.toHaveBeenCalled(); + (mockObserver.next).calls.reset(); + + // perform first manual search + component.performSearch(customCriteria); + + expect(mockObserver.next).toHaveBeenCalledTimes(1); + expect(mockObserver.next).toHaveBeenCalledWith(expectedResult); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + + expect(searchObsTeardown).toHaveBeenCalledTimes(1); + expect(genericSearchService.search).toHaveBeenCalledTimes(1); + expect(genericSearchService.search).toHaveBeenCalledWith(customCriteria); + + (genericSearchService.search).calls.reset(); + + // simulating searchState change due to first manual search => hasBeenSearched = true + searchState$.next({ + criteria: customCriteria, + hasBeenSearched: true + }); + + expect(genericSearchService.search).not.toHaveBeenCalled(); + }); + + it("should call progressService show/hide methods passing the progressTopic defined before and after performing the search", () => { + const expectedResult: MockResource[] = [{ uuid: "1", name: "first" }, { uuid: "2", name: "second" }]; + (genericSearchService.search).and.returnValue(of(expectedResult)); + + const dummyTopic: string = "dummyTopic"; + component.setProgressTopic(dummyTopic); + + component.ngOnInit(); + component.performSearch(); + + component.getResults().subscribe(mockObserver); + expect(mockObserver.next).toHaveBeenCalledTimes(1); + expect(mockObserver.next).toHaveBeenCalledWith(expectedResult); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + + expect(mockProgressService.show).toHaveBeenCalledTimes(1); + expect(mockProgressService.show).toHaveBeenCalledWith(dummyTopic); + expect(mockProgressService.hide).toHaveBeenCalledTimes(1); + expect(mockProgressService.hide).toHaveBeenCalledWith(dummyTopic); + }); + + it("should call progressService show/hide methods passing the progressTopic defined before and after performing a failing search", () => { + (genericSearchService.search).and.returnValue(throwError("search failed")); + + const dummyTopic: string = "dummyTopic"; + component.setProgressTopic(dummyTopic); + + component.ngOnInit(); + component.performSearch(); + + component.getResults().subscribe(); + + expect(mockProgressService.show).toHaveBeenCalledTimes(1); + expect(mockProgressService.show).toHaveBeenCalledWith(dummyTopic); + expect(mockProgressService.hide).toHaveBeenCalledTimes(1); + expect(mockProgressService.hide).toHaveBeenCalledWith(dummyTopic); + }); + + it("should NOT call progressService show/hide methods before and after performing the search in case no progressTopic is defined", () => { + const expectedResult: MockResource[] = [{ uuid: "1", name: "first" }, { uuid: "2", name: "second" }]; + (genericSearchService.search).and.returnValue(of(expectedResult)); + + component.setProgressTopic(""); + component.ngOnInit(); + component.performSearch(); + + component.getResults().subscribe(mockObserver); + expect(mockObserver.next).toHaveBeenCalledTimes(1); + expect(mockObserver.next).toHaveBeenCalledWith(expectedResult); + expect(mockObserver.error).not.toHaveBeenCalled(); + expect(mockObserver.complete).not.toHaveBeenCalled(); + + expect(mockProgressService.show).not.toHaveBeenCalled(); + expect(mockProgressService.hide).not.toHaveBeenCalled(); + }); + }); + + describe("latestResults", () => { + it("should return an empty array when no search has been performed yet as long as the preserveLatestResults option is enabled", () => { + component.enablePreserveLatestResults(true); + component.ngOnInit(); + + expect(component.latestResults).toEqual([]); + }); + + it("should return the results from the latest search that was performed as long as the preserveLatestResults option is enabled", () => { + const expectedResult: MockResource[] = [{ uuid: "1", name: "first" }, { uuid: "2", name: "second" }]; + (genericSearchService.search).and.returnValue(of(expectedResult)); + + component.enablePreserveLatestResults(true); + component.ngOnInit(); + component.performSearch(); + + expect(component.latestResults).toBe(expectedResult); + }); + + it("should return undefined regardless of any search that was performed when the preserveLatestResults option is disabled", () => { + const expectedResult: MockResource[] = [{ uuid: "1", name: "first" }, { uuid: "2", name: "second" }]; + (genericSearchService.search).and.returnValue(of(expectedResult)); + + component.enablePreserveLatestResults(false); + component.ngOnInit(); + component.performSearch(); + + expect(component.latestResults).toBe(undefined); + }); + }); +}); + +interface MockResource extends StarkResource { + name?: string; +} + +interface SearchCriteria { + uuid: string; +} + +function createObservableOf(value: T, teardown: Function): Observable { + return new Observable((subscriber: Subscriber) => { + subscriber.next(value); + return teardown; + }); +} + +class SearchComponentHelper extends AbstractStarkSearchComponent { + public constructor( + _genericSearchService_: StarkGenericSearchService, + logger: StarkLoggingService, + progressService: StarkProgressIndicatorService + ) { + super(_genericSearchService_, logger, progressService); + } + + public enablePreserveLatestResults(value: boolean): void { + this.preserveLatestResults = value; + } + + public getWorkingCopy(): MockResource { + return this.workingCopy; + } + + public getOriginalCopy(): MockResource { + return this.originalCopy; + } + + public setWorkingCopy(workingCopy: MockResource): void { + this.workingCopy = workingCopy; + } + + public updateOriginalCopy(originalCopy: MockResource): void { + this.originalCopy = originalCopy; + } + + public getResults(): Observable { + return this.results$; + } + + public getProgressTopic(): string { + return this.progressIndicatorConfig.topic; + } + + public setProgressTopic(topic: string): void { + this.progressIndicatorConfig.topic = topic; + } +} diff --git a/packages/stark-ui/src/modules/generic-search/classes/abstract-search-component.ts b/packages/stark-ui/src/modules/generic-search/classes/abstract-search-component.ts new file mode 100644 index 0000000000..c006e675c6 --- /dev/null +++ b/packages/stark-ui/src/modules/generic-search/classes/abstract-search-component.ts @@ -0,0 +1,196 @@ +import { ReplaySubject, Subscription } from "rxjs"; +import { map, take } from "rxjs/operators"; +import { OnDestroy, OnInit } from "@angular/core"; + +import { AbstractStarkFormComponent } from "../classes/abstract-form-component"; +import { StarkGenericSearchService } from "../classes/generic-search.service.intf"; +import { StarkSearchState } from "../entities/search-state.entity.intf"; +import { StarkErrorImpl, StarkLoggingService } from "@nationalbankbelgium/stark-core"; +import { StarkFormUtil } from "../../../util/form"; +import { FormGroup } from "@angular/forms"; +import { StarkProgressIndicatorConfig, StarkProgressIndicatorService, StarkProgressIndicatorType } from "../../progress-indicator"; + +/** + * Default progress indicator configuration + */ +const defaultProgressIndicatorConfig: StarkProgressIndicatorConfig = { + topic: "", + type: StarkProgressIndicatorType.SPINNER +}; + +export abstract class AbstractStarkSearchComponent extends AbstractStarkFormComponent + implements OnInit, OnDestroy { + /** @internal */ + private _latestResults: Readonly; + private searchStateSubscription: Subscription; + + /** + * Whether a new search should be performed automatically after initialization in case the last search criteria can be fetched + * from the application state (@ngrx/store) + */ + protected performSearchOnInit: boolean; + /** + * Whether the latest emitted results by the emitResult() method will be kept in the latestResults variable + */ + protected preserveLatestResults: boolean; + /** + * The config of the progress indicator to be shown/hidden when performing the search. + */ + public progressIndicatorConfig: StarkProgressIndicatorConfig; + /** + * Observable that will emit the search results. This Observable is created as soon as the Search Page controller is constructed + * and the first value it emits is an empty array in order to avoid the having undefined values passed down to the subscriber(s). + */ + protected results$: ReplaySubject; + + /** + * + * @param genericSearchService - Service implementing the StarkGenericSearchService interface providing all the necessary methods to search items. + * @param logger - The logging service of the application + * @param progressService - Service that provides an easy way to change the visibility of a progress indicator. + */ + protected constructor( + protected genericSearchService: StarkGenericSearchService, + logger: StarkLoggingService, + protected progressService: StarkProgressIndicatorService + ) { + super(logger); + + this.progressIndicatorConfig = defaultProgressIndicatorConfig; + this.performSearchOnInit = true; + this.preserveLatestResults = false; + } + + /** + * Component lifecycle hook + */ + public ngOnInit(): void { + // it will re-emit the latest value whenever a new observer subscribes to it + // so an empty array can be emitted as the result$ initial value (to avoid having undefined results) + this.results$ = new ReplaySubject(1); + // initially an empty array is emitted + this.emitResult([]); + + // if a search was done previously (saved in the Redux state) + // then the search is automatically performed on initialization (only if the performSearchOnInit option is TRUE) + this.searchStateSubscription = this.genericSearchService + .getSearchState() + .pipe( + map((searchState: StarkSearchState) => { + if (searchState.hasBeenSearched && this.performSearchOnInit) { + this.performSearch(searchState.criteria); + } + return searchState.criteria; + }) + ) + .subscribe((searchCriteria: CriteriaType) => { + this.setOriginalCopy(searchCriteria); + }); + } + + /** + * Component lifecycle hook + */ + public ngOnDestroy(): void { + this.searchStateSubscription.unsubscribe(); + } + + /** + * Invoke the search passing the formGroup's working copy only if the Search formGroup is valid + * @param formGroup - The 'search' formGroup + */ + public onSearch(formGroup: FormGroup): void { + if (StarkFormUtil.isFormGroupValid(formGroup)) { + this.performSearch(this.workingCopy); + } + } + + /** + * Invoke the genericSearchService.createNew() method + */ + public onNew(): void { + if (typeof this.genericSearchService.createNew !== "undefined") { + this.genericSearchService.createNew(); + } else { + throw new StarkErrorImpl( + 'AbstractStarkSearchComponent: When StarkGenericSearch component has the "new" button defined, ' + + 'the service must have an implementation for "createNew" method.' + ); + } + } + + /** + * Invoke the genericSearchService.resetSearchState() method and clears the results + */ + public onReset(form: FormGroup): void { + this.genericSearchService.resetSearchState(); + StarkFormUtil.setFormChildControlsState(form, ["untouched", "pristine"]); + StarkFormUtil.setFormGroupState(form, ["untouched", "pristine"]); + this.emitResult([]); + } + + /** + * Update the current working copy of the searchCriteria + * @param searchCriteria - New value of searchCriteria + */ + public updateWorkingCopy(searchCriteria: CriteriaType): void { + this.workingCopy = searchCriteria; + } + + /** + * Invoke the genericSearchService.search() method and emits the results. If no searchCriteria object is passed, then the current + * form's working copy is used. + */ + public performSearch(searchCriteria: CriteriaType = this.workingCopy): void { + this.performSearchOnInit = false; // prevent further automatic searches due to the subscription to StarkSearchState changes in NgOnInit + + this.showProgress(true); + + this.genericSearchService + .search(searchCriteria) + .pipe( + take(1) // this ensures that the observable will be automatically unsubscribed after emitting the value + ) + .subscribe( + (result: SearchResultsType[]) => { + this.emitResult(result); + this.showProgress(false); + }, + () => { + // hide the progress in case of error + this.showProgress(false); + } + ); + } + + /** + * The latest search results that have been emitted in the results$ Observable. + */ + public get latestResults(): Readonly { + return this._latestResults; + } + + /** + * Call the progressService show/hide methods in case there is a progressTopic defined + * @param show - Whether to show the progress indicator or not + */ + protected showProgress(show: boolean): void { + if (this.progressIndicatorConfig && this.progressIndicatorConfig.topic && this.progressIndicatorConfig.topic !== "") { + if (show) { + this.progressService.show(this.progressIndicatorConfig.topic); + } else { + this.progressService.hide(this.progressIndicatorConfig.topic); + } + } + } + + /** + * Emit the latest results and optionally keeps a reference to them if the preserveLatestResults option is enabled. + */ + protected emitResult(result: SearchResultsType[]): void { + if (this.preserveLatestResults) { + this._latestResults = result; + } + this.results$.next(result); + } +} diff --git a/packages/stark-ui/src/modules/generic-search/classes/generic-search.service.intf.ts b/packages/stark-ui/src/modules/generic-search/classes/generic-search.service.intf.ts new file mode 100644 index 0000000000..b1f3bf5f75 --- /dev/null +++ b/packages/stark-ui/src/modules/generic-search/classes/generic-search.service.intf.ts @@ -0,0 +1,30 @@ +import { Observable } from "rxjs"; +import { StarkSearchState } from "../entities/search-state.entity.intf"; + +/** + * Service to implement to use the Generic Search. + */ +export interface StarkGenericSearchService { + /** + * Prepares everything that is needed for creating a new item + */ + createNew?(): void; + + /** + * Fetch the current search state from Redux + * @returns The Redux search state + */ + getSearchState(): Observable>; + + /** + * Reset the current search state in Redux + */ + resetSearchState(): void; + + /** + * Performs the search with the given criteria + * @param criteria - The search criteria + * @returns The search results + */ + search(criteria: CriteriaType): Observable; +} diff --git a/packages/stark-ui/src/modules/generic-search/classes/search-form-component.intf.ts b/packages/stark-ui/src/modules/generic-search/classes/search-form-component.intf.ts new file mode 100644 index 0000000000..677788639a --- /dev/null +++ b/packages/stark-ui/src/modules/generic-search/classes/search-form-component.intf.ts @@ -0,0 +1,11 @@ +import { FormGroup } from "@angular/forms"; +import { EventEmitter } from "@angular/core"; + +/** + * Interface that must be implemented by the SearchForm component that will be used together with the Generic Search component. + * It defines the properties that are required and used by the Generic Search component. + */ +export interface StarkSearchFormComponent { + searchForm: FormGroup; + workingCopyChanged: EventEmitter; +} diff --git a/packages/stark-ui/src/modules/generic-search/components.ts b/packages/stark-ui/src/modules/generic-search/components.ts new file mode 100644 index 0000000000..9497044c25 --- /dev/null +++ b/packages/stark-ui/src/modules/generic-search/components.ts @@ -0,0 +1 @@ +export * from "./components/generic-search"; diff --git a/packages/stark-ui/src/modules/generic-search/components/generic-search.ts b/packages/stark-ui/src/modules/generic-search/components/generic-search.ts new file mode 100644 index 0000000000..3fb5802d60 --- /dev/null +++ b/packages/stark-ui/src/modules/generic-search/components/generic-search.ts @@ -0,0 +1 @@ +export * from "./generic-search/generic-search.component"; diff --git a/packages/stark-ui/src/modules/generic-search/components/generic-search/_generic-search.component.scss b/packages/stark-ui/src/modules/generic-search/components/generic-search/_generic-search.component.scss new file mode 100644 index 0000000000..3af4b760c1 --- /dev/null +++ b/packages/stark-ui/src/modules/generic-search/components/generic-search/_generic-search.component.scss @@ -0,0 +1,33 @@ +$button-spacing: 10px; + +.stark-generic-search { + > form { + overflow: hidden; + box-sizing: border-box; + padding-bottom: 4px; // If not set `box-shadow`from buttons is cut off + } + + .stark-form-actions { + display: flex; + flex-wrap: wrap; + + margin: -$button-spacing; + + button { + margin: $button-spacing; + width: calc(100% - #{$button-spacing} * 2); + + font-size: 20px; + line-height: 48px; + } + + @media #{$tablet-query} { + justify-content: flex-end; + button { + font-size: 13px; + line-height: 34px; + width: auto; + } + } + } +} diff --git a/packages/stark-ui/src/modules/generic-search/components/generic-search/generic-search.component.html b/packages/stark-ui/src/modules/generic-search/components/generic-search/generic-search.component.html new file mode 100644 index 0000000000..1e57b3e946 --- /dev/null +++ b/packages/stark-ui/src/modules/generic-search/components/generic-search/generic-search.component.html @@ -0,0 +1,85 @@ +
+ + + + + +
+ + + + + + +
+
diff --git a/packages/stark-ui/src/modules/generic-search/components/generic-search/generic-search.component.ts b/packages/stark-ui/src/modules/generic-search/components/generic-search/generic-search.component.ts new file mode 100644 index 0000000000..87fadb742f --- /dev/null +++ b/packages/stark-ui/src/modules/generic-search/components/generic-search/generic-search.component.ts @@ -0,0 +1,517 @@ +import { + AfterContentInit, + Component, + ContentChild, + ElementRef, + EventEmitter, + Inject, + Input, + OnChanges, + OnInit, + Output, + Renderer2, + SimpleChanges, + ViewEncapsulation +} from "@angular/core"; +import { StarkSearchFormComponent } from "../../classes"; +import { + StarkFormButton, + StarkFormCustomizablePredefinedButton, + StarkFormDefaultPredefinedButton, + StarkGenericSearchActionBarConfig, + StarkGenericSearchFormButtonsConfig +} from "../../entities"; +import { + StarkAction, + StarkActionBarConfig, + StarkCustomizablePredefinedAction, + StarkDefaultPredefinedAction +} from "../../../action-bar/components"; +import { STARK_LOGGING_SERVICE, StarkLoggingService } from "@nationalbankbelgium/stark-core"; +import { FormGroup } from "@angular/forms"; +import { animate, AnimationTriggerMetadata, state, style, transition, trigger } from "@angular/animations"; +import { AbstractStarkUiComponent } from "../../../../common/classes/abstract-component"; + +/** + * @ignore + */ +const _isEqual: Function = require("lodash/isEqual"); + +/** + * Name of the component + */ +const componentName: string = "stark-generic-search"; + +/** + * @ignore + */ +declare type UnusedLabelProps = "labelActivated" | "labelSwitchFunction"; + +/** + * @ignore + */ +declare type UnusedIconProps = "iconActivated" | "iconSwitchFunction"; + +/** + * @ignore + */ +declare type StarkDefaultPredefinedActionBarGenericAction = Required< + Pick> +> & + Pick; + +/** + * @ignore + */ +declare type StarkCustomizablePredefinedActionBarGenericAction = Required< + Pick> +> & + Partial>; + +/** + * @ignore + */ +interface StarkGenericSearchActionBarConfigRequired extends StarkGenericSearchActionBarConfig, StarkActionBarConfig { + search: StarkDefaultPredefinedActionBarGenericAction; + new: StarkCustomizablePredefinedActionBarGenericAction; + reset: StarkCustomizablePredefinedActionBarGenericAction; +} + +/** + * @ignore + */ +interface StarkGenericSearchFormButtonsConfigRequired extends StarkGenericSearchFormButtonsConfig { + search: Required; + new: Required; + reset: Required; + custom: StarkFormButton[]; +} + +/** + * @ignore + */ +const formAnimations: AnimationTriggerMetadata = trigger("collapse", [ + state("closed", style({ opacity: 0, height: 0 })), + transition("* <=> closed", animate(400)) +]); + +/** + * Component to display a generic search form with an action bar and form buttons for these actions: + * + * - New: call the onNew output function + * - Search: call the onSearch output function + * - Reset: call the onReset output function + * + */ +@Component({ + selector: "stark-generic-search", + templateUrl: "./generic-search.component.html", + animations: [formAnimations], + encapsulation: ViewEncapsulation.None, + // We need to use host instead of @HostBinding: https://github.com/NationalBankBelgium/stark/issues/664 + host: { + class: componentName + } +}) +export class StarkGenericSearchComponent extends AbstractStarkUiComponent implements OnInit, OnChanges, AfterContentInit { + /** + * Configuration object for the action bar to be shown above the generic form + */ + @Input() + public formActionBarConfig?: StarkGenericSearchActionBarConfig; + + /** + * Configuration object for the buttons to be shown in the generic form + */ + @Input() + public formButtonsConfig?: StarkGenericSearchFormButtonsConfig; + + /** + * Whether the search form should be hidden. Default: false + */ + @Input() + public isFormHidden: boolean = false; + + /** + * HTML id of action bar component. + */ + @Input() + public formHtmlId: string = "stark-generic-search-form"; + + /** + * Whether the search form should be hidden once the search is triggered. + * Default: false + */ + @Input() + public hideOnSearch: boolean = false; + + /** + * Callback function to be called when the "New" button is clicked (in case it is shown) + */ + @Output() + public newTriggered: EventEmitter = new EventEmitter(); + + /** + * Callback function to be called when the "Reset" button is clicked. + * The form model object is passed as parameter to this function. + */ + @Output() + public resetTriggered: EventEmitter = new EventEmitter(); + + /** + * Callback function to be called when the "Search" button is clicked. + * The form model object is passed as parameter to this function. + */ + @Output() + public searchTriggered: EventEmitter = new EventEmitter(); + + /** + * Callback function to be called when the visibility of the generic form changes. + * A boolean is passed as parameter to indicate whether the generic form is visible or not. + */ + @Output() + public formVisibilityChanged?: EventEmitter = new EventEmitter(); + + @ContentChild("searchForm") + public searchFormComponent: StarkSearchFormComponent; + + public actionBarConfig: StarkActionBarConfig; + public normalizedFormActionBarConfig: StarkGenericSearchActionBarConfigRequired; + public normalizedFormButtonsConfig: StarkGenericSearchFormButtonsConfigRequired; + public genericForm: FormGroup; + + /** + * @param logger - The logger of the application. + * @param renderer - Angular Renderer wrapper for DOM manipulations. + * @param elementRef - Reference to the DOM element where this directive is applied to. + */ + public constructor( + @Inject(STARK_LOGGING_SERVICE) private logger: StarkLoggingService, + protected renderer: Renderer2, + protected elementRef: ElementRef + ) { + super(renderer, elementRef); + } + + /** + * Component lifecycle hook + */ + public ngAfterContentInit(): void { + if (typeof this.searchFormComponent !== "undefined") { + this.genericForm = this.searchFormComponent.searchForm; + } + } + + /** + * Component lifecycle hook + */ + public ngOnInit(): void { + this.normalizedFormButtonsConfig = this.normalizeFormButtonsConfig(this.formButtonsConfig); + this.normalizedFormActionBarConfig = this.normalizeFormActionBarConfig(this.formActionBarConfig); + this.actionBarConfig = this.buildActionBarConfig(this.normalizedFormActionBarConfig); + + this.logger.debug(componentName + ": component initialized"); + } + + /** + * Component lifecycle hook + */ + public ngOnChanges(changesObj: SimpleChanges): void { + if ( + changesObj["formButtonsConfig"] && + !changesObj["formButtonsConfig"].isFirstChange() && + !_isEqual(this.formButtonsConfig, this.normalizedFormButtonsConfig) + ) { + this.normalizedFormButtonsConfig = this.normalizeFormButtonsConfig(this.formButtonsConfig); + } + + if ( + changesObj["formActionBarConfig"] && + !changesObj["formActionBarConfig"].isFirstChange() && + !_isEqual(this.formActionBarConfig, this.normalizedFormActionBarConfig) + ) { + this.normalizedFormActionBarConfig = this.normalizeFormActionBarConfig(this.formActionBarConfig); + this.actionBarConfig = this.buildActionBarConfig(this.normalizedFormActionBarConfig); + } + } + + /** + * Normalize the form buttons config + * Set a default value for each property of each button if there is no value defined + * @param config - Form buttons configuration + */ + public normalizeFormButtonsConfig(config?: StarkGenericSearchFormButtonsConfig): StarkGenericSearchFormButtonsConfigRequired { + config = config || {}; + + /** + * @ignore + */ + function normalizeButtonConfig( + buttonConfig: StarkFormCustomizablePredefinedButton, + defaultConfig: Required + ): Required { + return { + icon: typeof buttonConfig.icon !== "undefined" ? buttonConfig.icon : defaultConfig.icon, + label: typeof buttonConfig.label !== "undefined" ? buttonConfig.label : defaultConfig.label, + isEnabled: typeof buttonConfig.isEnabled !== "undefined" ? buttonConfig.isEnabled : defaultConfig.isEnabled, + isVisible: typeof buttonConfig.isVisible !== "undefined" ? buttonConfig.isVisible : defaultConfig.isVisible, + className: typeof buttonConfig.className !== "undefined" ? buttonConfig.className : defaultConfig.className, + buttonColor: typeof buttonConfig.buttonColor !== "undefined" ? buttonConfig.buttonColor : defaultConfig.buttonColor + }; + } + + /** + * @ignore + */ + function normalizeDefaultButtonConfig( + buttonConfig: StarkFormDefaultPredefinedButton, + defaultConfig: Required + ): Required { + return { + icon: typeof buttonConfig.icon !== "undefined" ? buttonConfig.icon : defaultConfig.icon, + label: typeof buttonConfig.label !== "undefined" ? buttonConfig.label : defaultConfig.label, + isEnabled: typeof buttonConfig.isEnabled !== "undefined" ? buttonConfig.isEnabled : defaultConfig.isEnabled, + className: typeof buttonConfig.className !== "undefined" ? buttonConfig.className : defaultConfig.className, + buttonColor: typeof buttonConfig.buttonColor !== "undefined" ? buttonConfig.buttonColor : defaultConfig.buttonColor + }; + } + + // set default values + const normalizedConfig: StarkGenericSearchFormButtonsConfigRequired = { + search: { + icon: "", + label: "STARK.ICONS.SEARCH", + isEnabled: true, + className: "mat-raised-button", + buttonColor: "primary" + }, + new: { + icon: "", + label: "STARK.ICONS.NEW_ITEM", + isEnabled: true, + isVisible: true, + className: "mat-stroked-button", + buttonColor: "primary" + }, + reset: { + icon: "", + label: "STARK.ICONS.RESET", + isEnabled: true, + isVisible: true, + className: "mat-stroked-button", + buttonColor: "primary" + }, + custom: [] + }; + + if (config.search) { + normalizedConfig.search = normalizeDefaultButtonConfig(config.search, normalizedConfig.search); + } + + if (config.new) { + normalizedConfig.new = normalizeButtonConfig(config.new, normalizedConfig.new); + } + + if (config.reset) { + normalizedConfig.reset = normalizeButtonConfig(config.reset, normalizedConfig.reset); + } + + if (config.custom !== undefined) { + normalizedConfig.custom = config.custom; + } + + return normalizedConfig; + } + + /** + * Normalize the form action bar config + * Set a default value for each property of each action if there is no value defined + * @param config - Form action bar configuration + */ + public normalizeFormActionBarConfig(config?: StarkGenericSearchActionBarConfig): StarkGenericSearchActionBarConfigRequired { + config = config || { actions: [] }; + + /** + * @ignore + */ + function normalizeDefaultActionConfig( + actionConfig: StarkDefaultPredefinedAction, + defaultConfig: StarkDefaultPredefinedActionBarGenericAction + ): StarkDefaultPredefinedActionBarGenericAction { + return { + icon: typeof actionConfig.icon !== "undefined" ? actionConfig.icon : defaultConfig.icon, + label: typeof actionConfig.label !== "undefined" ? actionConfig.label : defaultConfig.label, + isEnabled: typeof actionConfig.isEnabled !== "undefined" ? actionConfig.isEnabled : defaultConfig.isEnabled, + iconActivated: actionConfig.iconActivated, + iconSwitchFunction: actionConfig.iconSwitchFunction, + className: typeof actionConfig.className !== "undefined" ? actionConfig.className : defaultConfig.className, + buttonColor: typeof actionConfig.buttonColor !== "undefined" ? actionConfig.buttonColor : defaultConfig.buttonColor + }; + } + + /** + * @ignore + */ + function normalizeActionConfig( + actionConfig: StarkCustomizablePredefinedAction, + defaultConfig: StarkCustomizablePredefinedActionBarGenericAction + ): StarkCustomizablePredefinedActionBarGenericAction { + return { + icon: typeof actionConfig.icon !== "undefined" ? actionConfig.icon : defaultConfig.icon, + label: typeof actionConfig.label !== "undefined" ? actionConfig.label : defaultConfig.label, + isEnabled: typeof actionConfig.isEnabled !== "undefined" ? actionConfig.isEnabled : defaultConfig.isEnabled, + isVisible: typeof actionConfig.isVisible !== "undefined" ? actionConfig.isVisible : defaultConfig.isVisible, + iconActivated: actionConfig.iconActivated, + iconSwitchFunction: actionConfig.iconSwitchFunction, + className: typeof actionConfig.className !== "undefined" ? actionConfig.className : defaultConfig.className, + buttonColor: typeof actionConfig.buttonColor !== "undefined" ? actionConfig.buttonColor : defaultConfig.buttonColor + }; + } + + // set default values + const normalizedConfig: StarkGenericSearchActionBarConfigRequired = { + search: { + icon: "magnify", + label: "STARK.ICONS.SEARCH", + isEnabled: true, + className: "", + buttonColor: "primary" + }, + new: { + icon: "note-plus", + label: "STARK.ICONS.NEW_ITEM", + isEnabled: true, + isVisible: true, + className: "", + buttonColor: "primary" + }, + reset: { + icon: "undo", + label: "STARK.ICONS.RESET", + isEnabled: true, + isVisible: true, + className: "", + buttonColor: "primary" + }, + actions: [], + isPresent: true // action bar is present by default, should be explicitly set to false to remove it + }; + + if (config.search) { + normalizedConfig.search = normalizeDefaultActionConfig(config.search, normalizedConfig.search); + } + + if (config.new) { + normalizedConfig.new = normalizeActionConfig(config.new, normalizedConfig.new); + } + + if (config.reset) { + normalizedConfig.reset = normalizeActionConfig(config.reset, normalizedConfig.reset); + } + + if (config.actions !== undefined) { + normalizedConfig.actions = config.actions; + } + + if (config.isPresent !== undefined) { + normalizedConfig.isPresent = config.isPresent; + } + + return normalizedConfig; + } + + /** + * Build the action bar config object based on the searchFormActionBarConfig object and set the action calls to emit + * values through the defined Output + * @param searchFormActionBarConfig - Form action bar configuration + */ + public buildActionBarConfig(searchFormActionBarConfig: StarkGenericSearchActionBarConfigRequired): StarkActionBarConfig { + // TODO: replace this code with a Type to convert all optional props to required + // see https://github.com/JakeGinnivan/TypeScript-Handbook/commit/a02ef7023f2fb513dc35d8de35be23b926ec82e3 + const predefinedSearchAction: StarkDefaultPredefinedActionBarGenericAction = searchFormActionBarConfig.search; + const actionSearch: StarkAction = { + label: predefinedSearchAction.label, + icon: predefinedSearchAction.icon, + buttonColor: predefinedSearchAction.buttonColor, + isEnabled: predefinedSearchAction.isEnabled, + iconActivated: predefinedSearchAction.iconActivated, + iconSwitchFunction: predefinedSearchAction.iconSwitchFunction, + className: predefinedSearchAction.className, + id: "search-action-bar", + isVisible: true, + actionCall: (): void => { + this.searchTriggered.emit(this.genericForm); + } + }; + + const predefinedResetAction: StarkCustomizablePredefinedAction = searchFormActionBarConfig.reset; + const actionReset: StarkAction = { + label: predefinedResetAction.label, + icon: predefinedResetAction.icon, + isEnabled: predefinedResetAction.isEnabled, + isVisible: predefinedResetAction.isVisible, + iconActivated: predefinedResetAction.iconActivated, + iconSwitchFunction: predefinedResetAction.iconSwitchFunction, + className: predefinedResetAction.className, + id: "undo-action-bar", + actionCall: (): void => { + if (this.resetTriggered) { + this.resetTriggered.emit(this.genericForm); + } + } + }; + + const predefinedNewAction: StarkCustomizablePredefinedAction = searchFormActionBarConfig.new; + const actionNew: StarkAction = { + label: predefinedNewAction.label, + icon: predefinedNewAction.icon, + isEnabled: predefinedNewAction.isEnabled, + isVisible: predefinedNewAction.isVisible, + iconActivated: predefinedNewAction.iconActivated, + iconSwitchFunction: predefinedNewAction.iconSwitchFunction, + className: predefinedNewAction.className, + id: "new-action-bar", + actionCall: (): void => { + if (this.newTriggered) { + this.newTriggered.emit(); + } + } + }; + + return { + isPresent: searchFormActionBarConfig.isPresent, + actions: [actionNew, actionSearch, actionReset, ...searchFormActionBarConfig.actions] + }; + } + + /** + * Emit the form through "searchTriggered" event emitter then hide the search form based on hideOnSearch variable + */ + public triggerSearch(): void { + this.searchTriggered.emit(this.genericForm); + if (this.hideOnSearch) { + this.hideForm(); + } + } + + /** + * Hide the search form and emit a boolean through formVisibilityChanged event emitter that indicates if the search is displayed + */ + public hideForm(): void { + if (!this.isFormHidden) { + this.isFormHidden = true; + + // by the moment, the callback is called only when the form is hidden + if (this.formVisibilityChanged) { + this.formVisibilityChanged.emit(!this.isFormHidden); + } + } + } + + /** + * @ignore + */ + public trackItemFn(formButton: StarkFormButton): string { + return formButton.id; + } +} diff --git a/packages/stark-ui/src/modules/generic-search/entities.ts b/packages/stark-ui/src/modules/generic-search/entities.ts new file mode 100644 index 0000000000..a33e75fd10 --- /dev/null +++ b/packages/stark-ui/src/modules/generic-search/entities.ts @@ -0,0 +1,4 @@ +export * from "./entities/form-action.intf"; +export * from "./entities/generic-search-form-buttons-config.intf"; +export * from "./entities/generic-search-action-bar-config.intf"; +export * from "./entities/search-state.entity.intf"; diff --git a/packages/stark-ui/src/modules/generic-search/entities/form-action.intf.ts b/packages/stark-ui/src/modules/generic-search/entities/form-action.intf.ts new file mode 100644 index 0000000000..26380013e9 --- /dev/null +++ b/packages/stark-ui/src/modules/generic-search/entities/form-action.intf.ts @@ -0,0 +1,78 @@ +import { StarkActionBarButtonColor } from "../../action-bar/components"; + +/** + * Predefined colors for form buttons + */ +export type StarkFormButtonColor = StarkActionBarButtonColor; + +/** + * Base definition of a form button to be used in Stark + * Provides a defined API for buttons configuration which can be reused by any client application if they want too. + */ +export interface StarkFormButtonBase { + /** + * Color of the button + */ + buttonColor?: StarkFormButtonColor | string; + + /** + * Path to SVG icon from iconSets to display inside the form button. Ex: "pencil" + */ + icon?: string; + + /** + * Text to be displayed as label of the form button + */ + label?: string; + + /** + * Whether the form button should be enabled for user interaction or not + */ + isEnabled?: boolean; + + /** + * Custom CSS class to be set to the form button + */ + className?: string; +} + +/** + * Definition of a form button to be used in Stark + * Provides a defined API for buttons configuration which can be reused by any client application if they want too. + */ +export interface StarkFormButton extends StarkFormButtonBase { + /** + * The HTML id to be set to the form button + */ + id: string; + + /** + * Text to be displayed as label of the form button + */ + label: string; + + /** + * Function to be fired when form button is clicked + */ + onClick: Function; + + /** + * Whether the form button will be visible or not + */ + isVisible?: boolean; +} + +/** + * Definition of a form's default button to be used in an Stark generic form + */ +export interface StarkFormDefaultPredefinedButton extends StarkFormButtonBase {} + +/** + * Definition of a form's normal button to be used in an Stark generic form + */ +export interface StarkFormCustomizablePredefinedButton extends StarkFormButtonBase { + /** + * Whether the form button will be visible or not + */ + isVisible?: boolean; +} diff --git a/packages/stark-ui/src/modules/generic-search/entities/generic-search-action-bar-config.intf.ts b/packages/stark-ui/src/modules/generic-search/entities/generic-search-action-bar-config.intf.ts new file mode 100644 index 0000000000..7670ca7591 --- /dev/null +++ b/packages/stark-ui/src/modules/generic-search/entities/generic-search-action-bar-config.intf.ts @@ -0,0 +1,21 @@ +import { StarkActionBarConfig, StarkCustomizablePredefinedAction, StarkDefaultPredefinedAction } from "../../action-bar"; + +/** + * This interface describes the properties of the action bar displayed in the generic search component. + */ +export interface StarkGenericSearchActionBarConfig extends StarkActionBarConfig { + /** + * Configuration of the search action displayed in the Generic Search component + */ + search?: StarkDefaultPredefinedAction; + + /** + * Configuration of the new action displayed in the Generic Search component + */ + new?: StarkCustomizablePredefinedAction; + + /** + * Configuration of the reset action displayed in the Generic Search component + */ + reset?: StarkCustomizablePredefinedAction; +} diff --git a/packages/stark-ui/src/modules/generic-search/entities/generic-search-form-buttons-config.intf.ts b/packages/stark-ui/src/modules/generic-search/entities/generic-search-form-buttons-config.intf.ts new file mode 100644 index 0000000000..1661213f26 --- /dev/null +++ b/packages/stark-ui/src/modules/generic-search/entities/generic-search-form-buttons-config.intf.ts @@ -0,0 +1,26 @@ +import { StarkFormButton, StarkFormCustomizablePredefinedButton, StarkFormDefaultPredefinedButton } from "./form-action.intf"; + +/** + * This interface describes the properties of the buttons displayed below of the search form in the generic search component. + */ +export interface StarkGenericSearchFormButtonsConfig { + /** + * Configuration of the search button displayed in the Generic Search component + */ + search?: StarkFormDefaultPredefinedButton; + + /** + * Configuration of the new button displayed in the Generic Search component + */ + new?: StarkFormCustomizablePredefinedButton; + + /** + * Configuration of the reset button displayed in the Generic Search component + */ + reset?: StarkFormCustomizablePredefinedButton; + + /** + * Array of {StarkFormButton} buttons that can be displayed beside the default buttons in the Generic Search component + */ + custom?: StarkFormButton[]; +} diff --git a/packages/stark-ui/src/modules/generic-search/entities/search-state.entity.intf.ts b/packages/stark-ui/src/modules/generic-search/entities/search-state.entity.intf.ts new file mode 100644 index 0000000000..ee87d3c37d --- /dev/null +++ b/packages/stark-ui/src/modules/generic-search/entities/search-state.entity.intf.ts @@ -0,0 +1,14 @@ +/** + * This interface describes the object which is stored in @ngrx/store and used by the Generic Search to make its job. + */ +export interface StarkSearchState { + /** + * Indicates if the searchState has been already searched. + */ + hasBeenSearched?: boolean; + + /** + * Criteria to be used for a search. + */ + criteria: T; +} diff --git a/packages/stark-ui/src/modules/generic-search/generic-search.module.ts b/packages/stark-ui/src/modules/generic-search/generic-search.module.ts new file mode 100644 index 0000000000..c68c5a1031 --- /dev/null +++ b/packages/stark-ui/src/modules/generic-search/generic-search.module.ts @@ -0,0 +1,27 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { MatButtonModule } from "@angular/material/button"; +import { MatIconModule } from "@angular/material/icon"; +import { MatTooltipModule } from "@angular/material/tooltip"; +import { TranslateModule } from "@ngx-translate/core"; +import { StarkGenericSearchComponent } from "./components"; +import { StarkActionBarModule } from "../action-bar"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; + +@NgModule({ + declarations: [StarkGenericSearchComponent], + imports: [ + BrowserAnimationsModule, + CommonModule, + FormsModule, + ReactiveFormsModule, + MatButtonModule, + MatIconModule, + MatTooltipModule, + StarkActionBarModule, + TranslateModule + ], + exports: [StarkGenericSearchComponent] +}) +export class StarkGenericSearchModule {} diff --git a/packages/stark-ui/src/util/form/form.util.spec.ts b/packages/stark-ui/src/util/form/form.util.spec.ts index 9aaaa5af77..4a5db0909b 100644 --- a/packages/stark-ui/src/util/form/form.util.spec.ts +++ b/packages/stark-ui/src/util/form/form.util.spec.ts @@ -12,14 +12,18 @@ describe("Util: FormUtil", () => { let mockFormControls: FormControl[]; function getMockFormControl(name: string): FormControl { - return (jasmine.createSpyObj(name, [ - "value", - "setValue", - "markAsUntouched", - "markAsTouched", - "markAsPristine", - "markAsDirty" - ])); + return + (( + jasmine.createSpyObj(name, [ + "value", + "setValue", + "markAsUntouched", + "markAsTouched", + "markAsPristine", + "markAsDirty" + ]) + ) + ); } function assertFormControl(formItem: FormControl, newState?: string): void { diff --git a/showcase/src/app/app-menu.config.ts b/showcase/src/app/app-menu.config.ts index d8cfc43676..6805bae8fa 100644 --- a/showcase/src/app/app-menu.config.ts +++ b/showcase/src/app/app-menu.config.ts @@ -115,6 +115,13 @@ export const APP_MENU_CONFIG: StarkMenuConfig = { isEnabled: true, targetState: "demo-ui.stark-footer" }, + { + id: "menu-style-generic-search", + label: "Generic Search", + isVisible: true, + isEnabled: true, + targetState: "demo-ui.generic-search" + }, { id: "menu-stark-ui-components-language-selector", label: "Language selector", diff --git a/showcase/src/app/app.module.ts b/showcase/src/app/app.module.ts index 1f5e76452c..4378df5ced 100644 --- a/showcase/src/app/app.module.ts +++ b/showcase/src/app/app.module.ts @@ -63,6 +63,7 @@ import { StarkToastNotificationModule } from "@nationalbankbelgium/stark-ui"; import { SharedModule } from "./shared/shared.module"; +import { DemoUiModule } from "./demo-ui/demo-ui.module"; import { InMemoryDataModule } from "./in-memory-data/in-memory-data.module"; import { WelcomeModule } from "./welcome/welcome.module"; import { logRegisteredStates, routerConfigFn } from "./router.config"; @@ -213,6 +214,11 @@ export const metaReducers: MetaReducer[] = ENV !== "production" ? [logger } }), SharedModule, + /** + * FIXME Remove this when we find a solution to make reducers working with lazy loading and we find a way to only + * import the MatAutocompleteModule in the demo UI Module (and not in the app module) with lazy loading + */ + DemoUiModule, WelcomeModule, StarkAppFooterModule, StarkAppDataModule, diff --git a/showcase/src/app/demo-ui/demo-ui.module.ts b/showcase/src/app/demo-ui/demo-ui.module.ts index 7e3037f73a..87a9d812e2 100644 --- a/showcase/src/app/demo-ui/demo-ui.module.ts +++ b/showcase/src/app/demo-ui/demo-ui.module.ts @@ -1,7 +1,8 @@ import { NgModule } from "@angular/core"; import { CommonModule } from "@angular/common"; -import { FormsModule } from "@angular/forms"; -import { MAT_DATE_FORMATS } from "@angular/material/core"; +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { MAT_DATE_FORMATS, MatOptionModule } from "@angular/material/core"; +import { MatAutocompleteModule } from "@angular/material/autocomplete"; import { MatCheckboxModule } from "@angular/material/checkbox"; import { MatButtonModule } from "@angular/material/button"; import { MatButtonToggleModule } from "@angular/material/button-toggle"; @@ -13,6 +14,8 @@ import { MatTooltipModule } from "@angular/material/tooltip"; import { MatSnackBarModule } from "@angular/material/snack-bar"; import { MatFormFieldModule } from "@angular/material/form-field"; import { MatInputModule } from "@angular/material/input"; +import { MatSlideToggleModule } from "@angular/material/slide-toggle"; +import { StoreModule } from "@ngrx/store"; import { TranslateModule } from "@ngx-translate/core"; import { UIRouterModule } from "@uirouter/angular"; import { @@ -26,6 +29,7 @@ import { StarkDatePickerModule, StarkDateRangePickerModule, StarkDropdownModule, + StarkGenericSearchModule, StarkKeyboardDirectivesModule, StarkLanguageSelectorModule, StarkMinimapModule, @@ -46,6 +50,8 @@ import { DemoDateRangePickerPageComponent, DemoDropdownPageComponent, DemoFooterPageComponent, + DemoGenericSearchPageComponent, + DemoGenericService, DemoKeyboardDirectivesPageComponent, DemoLanguageSelectorPageComponent, DemoLogoutPageComponent, @@ -71,6 +77,8 @@ import { TableWithSelectionComponent, TableWithTranscludedActionBarComponent } from "./components"; +import { DemoGenericSearchFormComponent, demoGenericSearchReducers } from "./pages/generic-search"; +import { MatExpansionModule } from "@angular/material/expansion"; @NgModule({ imports: [ @@ -79,17 +87,22 @@ import { }), CommonModule, FormsModule, + ReactiveFormsModule, + MatAutocompleteModule, MatButtonModule, MatButtonToggleModule, MatCardModule, MatCheckboxModule, MatDividerModule, + MatExpansionModule, MatFormFieldModule, MatIconModule, MatInputModule, + MatOptionModule, MatTooltipModule, MatSnackBarModule, MatTabsModule, + MatSlideToggleModule, TranslateModule, SharedModule, StarkActionBarModule, @@ -101,6 +114,7 @@ import { StarkDatePickerModule, StarkDateRangePickerModule, StarkDropdownModule, + StarkGenericSearchModule, StarkKeyboardDirectivesModule, StarkLanguageSelectorModule, StarkMinimapModule, @@ -110,7 +124,8 @@ import { StarkRouteSearchModule, StarkSliderModule, StarkSvgViewBoxModule, - StarkTableModule + StarkTableModule, + StoreModule.forFeature("DemoGenericSearch", demoGenericSearchReducers) ], declarations: [ DemoActionBarPageComponent, @@ -121,6 +136,7 @@ import { DemoDateRangePickerPageComponent, DemoDropdownPageComponent, DemoFooterPageComponent, + DemoGenericSearchPageComponent, DemoKeyboardDirectivesPageComponent, DemoLanguageSelectorPageComponent, DemoLogoutPageComponent, @@ -140,7 +156,8 @@ import { TableWithTranscludedActionBarComponent, TableWithFixedHeaderComponent, TableWithCustomStylingComponent, - DemoToastPageComponent + DemoToastPageComponent, + DemoGenericSearchFormComponent ], exports: [ DemoActionBarPageComponent, @@ -151,6 +168,7 @@ import { DemoDateRangePickerPageComponent, DemoDropdownPageComponent, DemoFooterPageComponent, + DemoGenericSearchPageComponent, DemoKeyboardDirectivesPageComponent, DemoLanguageSelectorPageComponent, DemoLogoutPageComponent, @@ -163,8 +181,9 @@ import { DemoRouteSearchPageComponent, DemoSliderPageComponent, DemoTablePageComponent, - DemoToastPageComponent + DemoToastPageComponent, + DemoGenericSearchFormComponent ], - providers: [{ provide: MAT_DATE_FORMATS, useValue: STARK_DATE_FORMATS }] + providers: [{ provide: MAT_DATE_FORMATS, useValue: STARK_DATE_FORMATS }, DemoGenericService] }) export class DemoUiModule {} diff --git a/showcase/src/app/demo-ui/pages/generic-search/actions/demo-generic-search.actions.ts b/showcase/src/app/demo-ui/pages/generic-search/actions/demo-generic-search.actions.ts new file mode 100644 index 0000000000..f107f8ae88 --- /dev/null +++ b/showcase/src/app/demo-ui/pages/generic-search/actions/demo-generic-search.actions.ts @@ -0,0 +1,49 @@ +import { Action } from "@ngrx/store"; +import { HeroMovieSearchCriteria } from "../entities"; + +export enum DemoGenericActionTypes { + SET_DEMO_GENERIC_SEARCH_CRITERIA = "[DemoGenericSearch] Set criteria", + REMOVE_DEMO_GENERIC_SEARCH_CRITERIA = "[DemoGenericSearch] Remove criteria", + DEMO_GENERIC_HAS_SEARCHED = "[DemoGenericSearch] Has searched", + DEMO_GENERIC_HAS_SEARCHED_RESET = "[DemoGenericSearch] Has searched reset" +} + +export class DemoGenericSearchSetCriteria implements Action { + /** + * The type of action + */ + public readonly type: DemoGenericActionTypes.SET_DEMO_GENERIC_SEARCH_CRITERIA = DemoGenericActionTypes.SET_DEMO_GENERIC_SEARCH_CRITERIA; + /** + * Class constructor + * @param criteria - Criteria to be set + */ + public constructor(public criteria: HeroMovieSearchCriteria) {} +} + +export class DemoGenericSearchRemoveCriteria implements Action { + /** + * The type of action + */ + public readonly type: DemoGenericActionTypes.REMOVE_DEMO_GENERIC_SEARCH_CRITERIA = + DemoGenericActionTypes.REMOVE_DEMO_GENERIC_SEARCH_CRITERIA; +} + +export class DemoGenericSearchHasSearched implements Action { + /** + * The type of action + */ + public readonly type: DemoGenericActionTypes.DEMO_GENERIC_HAS_SEARCHED = DemoGenericActionTypes.DEMO_GENERIC_HAS_SEARCHED; +} + +export class DemoGenericSearchHasSearchedReset implements Action { + /** + * The type of action + */ + public readonly type: DemoGenericActionTypes.DEMO_GENERIC_HAS_SEARCHED_RESET = DemoGenericActionTypes.DEMO_GENERIC_HAS_SEARCHED_RESET; +} + +export type DemoGenericSearchActions = + | DemoGenericSearchRemoveCriteria + | DemoGenericSearchHasSearchedReset + | DemoGenericSearchHasSearched + | DemoGenericSearchSetCriteria; diff --git a/showcase/src/app/demo-ui/pages/generic-search/actions/index.ts b/showcase/src/app/demo-ui/pages/generic-search/actions/index.ts new file mode 100644 index 0000000000..e0dcfc50fa --- /dev/null +++ b/showcase/src/app/demo-ui/pages/generic-search/actions/index.ts @@ -0,0 +1 @@ +export * from "./demo-generic-search.actions"; diff --git a/showcase/src/app/demo-ui/pages/generic-search/components/demo-generic-search-form.component.html b/showcase/src/app/demo-ui/pages/generic-search/components/demo-generic-search-form.component.html new file mode 100644 index 0000000000..09ed07dae3 --- /dev/null +++ b/showcase/src/app/demo-ui/pages/generic-search/components/demo-generic-search-form.component.html @@ -0,0 +1,48 @@ +
+ +
+ + + + + {{ option }} + +
+ +
+ + + + + {{ option }} + +
+ + +
+ + + + + {{ option }} + +
+
diff --git a/showcase/src/app/demo-ui/pages/generic-search/components/demo-generic-search-form.component.ts b/showcase/src/app/demo-ui/pages/generic-search/components/demo-generic-search-form.component.ts new file mode 100644 index 0000000000..94c4129906 --- /dev/null +++ b/showcase/src/app/demo-ui/pages/generic-search/components/demo-generic-search-form.component.ts @@ -0,0 +1,78 @@ +import { Component, EventEmitter, Inject, Input, OnInit, Output } from "@angular/core"; +import { StarkSearchFormComponent } from "@nationalbankbelgium/stark-ui"; +import { HeroMovieSearchCriteria } from "../entities"; +import { FormBuilder, FormGroup } from "@angular/forms"; +import { STARK_LOGGING_SERVICE, StarkLoggingService } from "@nationalbankbelgium/stark-core"; +import { DemoGenericService } from "../services"; +import { take } from "rxjs/operators"; + +const componentName: string = "demo-generic-search-form"; + +@Component({ + selector: "demo-generic-search-form", + templateUrl: "./demo-generic-search-form.component.html" +}) +export class DemoGenericSearchFormComponent implements OnInit, StarkSearchFormComponent { + @Input() + public searchCriteria: HeroMovieSearchCriteria = {}; + + @Output() + public workingCopyChanged: EventEmitter = new EventEmitter(); + + public yearOptions: number[] = []; + public heroOptions: string[] = []; + public movieOptions: string[] = []; + + public searchForm: FormGroup; + + public constructor( + @Inject(STARK_LOGGING_SERVICE) private logger: StarkLoggingService, + private genericService: DemoGenericService, + private formBuilder: FormBuilder + ) {} + + public ngOnInit(): void { + this.searchForm = this.formBuilder.group({ + year: this.searchCriteria.year, + hero: this.searchCriteria.hero, + movie: this.searchCriteria.movie + }); + + this.searchForm.valueChanges.subscribe(() => { + const modifiedCriteria: HeroMovieSearchCriteria = this.mapFormGroupToSearchCriteria(this.searchForm); + this.workingCopyChanged.emit(modifiedCriteria); + }); + + this.genericService + .getHeroes() + .pipe(take(1)) + .subscribe((heroes: string[]) => (this.heroOptions = heroes)); + this.genericService + .getYears() + .pipe(take(1)) + .subscribe((years: number[]) => (this.yearOptions = years)); + this.genericService + .getMovies() + .pipe(take(1)) + .subscribe((movies: string[]) => (this.movieOptions = movies)); + + this.logger.debug(componentName + " is initialized"); + } + + public mapFormGroupToSearchCriteria(formGroup: FormGroup): HeroMovieSearchCriteria { + /// return formGroup.getRawValue(); + + return { + year: formGroup.controls["year"].value, + hero: formGroup.controls["hero"].value, + movie: formGroup.controls["movie"].value + }; + } + + /** + * @ignore + */ + public trackItemFn(item: string): string { + return item; + } +} diff --git a/showcase/src/app/demo-ui/pages/generic-search/components/index.ts b/showcase/src/app/demo-ui/pages/generic-search/components/index.ts new file mode 100644 index 0000000000..5e143a52f7 --- /dev/null +++ b/showcase/src/app/demo-ui/pages/generic-search/components/index.ts @@ -0,0 +1 @@ +export * from "./demo-generic-search-form.component"; diff --git a/showcase/src/app/demo-ui/pages/generic-search/demo-generic-search-page.component.html b/showcase/src/app/demo-ui/pages/generic-search/demo-generic-search-page.component.html new file mode 100644 index 0000000000..ab5359ace8 --- /dev/null +++ b/showcase/src/app/demo-ui/pages/generic-search/demo-generic-search-page.component.html @@ -0,0 +1,36 @@ +

SHOWCASE.DEMO.GENERIC_SEARCH.TITLE

+
+

SHOWCASE.DEMO.SHARED.EXAMPLE_VIEWER_LIST

+ + + {{ "SHOWCASE.DEMO.GENERIC_SEARCH.TOGGLE" | translate }} + + + + + +

SHOWCASE.DEMO.GENERIC_SEARCH.SEARCH_RESULTS

+
+
+ + +
diff --git a/showcase/src/app/demo-ui/pages/generic-search/demo-generic-search-page.component.ts b/showcase/src/app/demo-ui/pages/generic-search/demo-generic-search-page.component.ts new file mode 100644 index 0000000000..1520cb58ab --- /dev/null +++ b/showcase/src/app/demo-ui/pages/generic-search/demo-generic-search-page.component.ts @@ -0,0 +1,95 @@ +import { Component, Inject, OnDestroy, OnInit } from "@angular/core"; +import { STARK_LOGGING_SERVICE, StarkLoggingService } from "@nationalbankbelgium/stark-core"; +import { ReferenceLink } from "../../../shared"; +import { + AbstractStarkSearchComponent, + STARK_PROGRESS_INDICATOR_SERVICE, + StarkPaginationConfig, + StarkProgressIndicatorService, + StarkTableColumnProperties +} from "@nationalbankbelgium/stark-ui"; +import { HeroMovie, HeroMovieSearchCriteria } from "./entities"; +import { DemoGenericService } from "./services"; + +@Component({ + selector: "demo-generic-search", + templateUrl: "./demo-generic-search-page.component.html" +}) +export class DemoGenericSearchPageComponent extends AbstractStarkSearchComponent + implements OnInit, OnDestroy { + public hideSearch: boolean; + + public referenceList: ReferenceLink[] = [ + { + label: "Stark Generic Search component API", + url: "https://stark.nbb.be/api-docs/stark-ui/latest/components/StarkGenericSearchComponent.html" + }, + { + label: "SHOWCASE.DEMO.GENERIC_SEARCH.EXPLANATION_ABOUT_DOC_ON_GITHUB", + url: "https://github.com/NationalBankBelgium/stark/blob/master/docs/GENERIC_SEARCH.md" + } + ]; + + public columnsProperties: StarkTableColumnProperties[]; + public searchResults: HeroMovie[]; + public paginationConfig: StarkPaginationConfig; + + public constructor( + @Inject(STARK_LOGGING_SERVICE) logger: StarkLoggingService, + demoGenericService: DemoGenericService, + @Inject(STARK_PROGRESS_INDICATOR_SERVICE) progressIndicatorService: StarkProgressIndicatorService + ) { + super(demoGenericService, logger, progressIndicatorService); + + this.progressIndicatorConfig.topic = "demo-generic-search"; // Set the progress topic to make the progressService working + this.performSearchOnInit = true; // Turn on automatic search (last search criteria) + this.preserveLatestResults = true; // Keep a reference to the latest results in the latestResults variable + } + + /** + * Component lifecycle hook + */ + public ngOnInit(): void { + super.ngOnInit(); + + this.results$.subscribe((genericObjects: HeroMovie[]) => (this.searchResults = genericObjects)); + + this.columnsProperties = [ + { + name: "hero", + label: "Hero", + isFilterable: true, + isSortable: true + }, + { + name: "movie", + label: "Movie", + isFilterable: true, + isSortable: true + }, + { + name: "year", + label: "Year", + isFilterable: true, + isSortable: true + } + ]; + + this.paginationConfig = { + isExtended: false, + itemsPerPage: 10, + itemsPerPageOptions: [10, 20, 50], + itemsPerPageIsPresent: true, + page: 1, + pageNavIsPresent: true, + pageInputIsPresent: true + }; + } + + /** + * Component lifecycle hook + */ + public ngOnDestroy(): void { + super.ngOnDestroy(); + } +} diff --git a/showcase/src/app/demo-ui/pages/generic-search/entities/hero-movie-search.entity.ts b/showcase/src/app/demo-ui/pages/generic-search/entities/hero-movie-search.entity.ts new file mode 100644 index 0000000000..d2be617a24 --- /dev/null +++ b/showcase/src/app/demo-ui/pages/generic-search/entities/hero-movie-search.entity.ts @@ -0,0 +1,9 @@ +export class HeroMovieSearchCriteria { + public year?: string; + public hero?: string; + public movie?: string; + + public constructor() { + // empty constructor + } +} diff --git a/showcase/src/app/demo-ui/pages/generic-search/entities/hero-movie.entity.ts b/showcase/src/app/demo-ui/pages/generic-search/entities/hero-movie.entity.ts new file mode 100644 index 0000000000..722839b4ac --- /dev/null +++ b/showcase/src/app/demo-ui/pages/generic-search/entities/hero-movie.entity.ts @@ -0,0 +1,16 @@ +import { StarkResource } from "@nationalbankbelgium/stark-core"; +import { autoserialize } from "cerialize"; + +export class HeroMovie implements StarkResource { + @autoserialize + public uuid: string; + + @autoserialize + public year: number; + + @autoserialize + public hero: string; + + @autoserialize + public movie: string; +} diff --git a/showcase/src/app/demo-ui/pages/generic-search/entities/index.ts b/showcase/src/app/demo-ui/pages/generic-search/entities/index.ts new file mode 100644 index 0000000000..1890f5feb5 --- /dev/null +++ b/showcase/src/app/demo-ui/pages/generic-search/entities/index.ts @@ -0,0 +1,2 @@ +export * from "./hero-movie-search.entity"; +export * from "./hero-movie.entity"; diff --git a/showcase/src/app/demo-ui/pages/generic-search/index.ts b/showcase/src/app/demo-ui/pages/generic-search/index.ts new file mode 100644 index 0000000000..fe57e92a21 --- /dev/null +++ b/showcase/src/app/demo-ui/pages/generic-search/index.ts @@ -0,0 +1,6 @@ +export * from "./actions"; +export * from "./components"; +export * from "./entities"; +export * from "./reducers"; +export * from "./services"; +export * from "./demo-generic-search-page.component"; diff --git a/showcase/src/app/demo-ui/pages/generic-search/reducers/demo-generic-search.reducer.ts b/showcase/src/app/demo-ui/pages/generic-search/reducers/demo-generic-search.reducer.ts new file mode 100644 index 0000000000..f05229500d --- /dev/null +++ b/showcase/src/app/demo-ui/pages/generic-search/reducers/demo-generic-search.reducer.ts @@ -0,0 +1,26 @@ +import { StarkSearchState } from "@nationalbankbelgium/stark-ui"; +import { HeroMovieSearchCriteria } from "../entities/hero-movie-search.entity"; +import { DemoGenericActionTypes, DemoGenericSearchActions } from "../actions"; + +const INITIAL_STATE: Readonly> = { + criteria: new HeroMovieSearchCriteria(), + hasBeenSearched: false +}; + +export function demoGenericSearchReducer( + state: Readonly> = INITIAL_STATE, + action: Readonly +): Readonly> { + switch (action.type) { + case DemoGenericActionTypes.SET_DEMO_GENERIC_SEARCH_CRITERIA: + return { ...state, criteria: action.criteria }; + case DemoGenericActionTypes.REMOVE_DEMO_GENERIC_SEARCH_CRITERIA: + return { ...state, criteria: INITIAL_STATE.criteria }; + case DemoGenericActionTypes.DEMO_GENERIC_HAS_SEARCHED: + return { ...state, hasBeenSearched: true }; + case DemoGenericActionTypes.DEMO_GENERIC_HAS_SEARCHED_RESET: + return { ...state, hasBeenSearched: false }; + default: + return state; + } +} diff --git a/showcase/src/app/demo-ui/pages/generic-search/reducers/index.ts b/showcase/src/app/demo-ui/pages/generic-search/reducers/index.ts new file mode 100644 index 0000000000..a40b60ff3b --- /dev/null +++ b/showcase/src/app/demo-ui/pages/generic-search/reducers/index.ts @@ -0,0 +1,18 @@ +import { StarkSearchState } from "@nationalbankbelgium/stark-ui"; +import { ActionReducerMap, createSelector, MemoizedSelector, createFeatureSelector } from "@ngrx/store"; +import { HeroMovieSearchCriteria } from "../entities"; +import { DemoGenericSearchActions } from "../actions"; +import { demoGenericSearchReducer } from "./demo-generic-search.reducer"; + +export interface DemoGenericSearchState { + demoGenericSearch: StarkSearchState; +} + +export const demoGenericSearchReducers: ActionReducerMap = { + demoGenericSearch: demoGenericSearchReducer +}; + +export const selectDemoGenericSearch: MemoizedSelector> = createSelector( + createFeatureSelector("DemoGenericSearch"), + (state: DemoGenericSearchState) => state.demoGenericSearch +); diff --git a/showcase/src/app/demo-ui/pages/generic-search/services/demo-generic.service.ts b/showcase/src/app/demo-ui/pages/generic-search/services/demo-generic.service.ts new file mode 100644 index 0000000000..4cc3a9fc4d --- /dev/null +++ b/showcase/src/app/demo-ui/pages/generic-search/services/demo-generic.service.ts @@ -0,0 +1,140 @@ +import { Injectable } from "@angular/core"; +import { Observable, of } from "rxjs"; +import { map, delay } from "rxjs/operators"; +import { StarkGenericSearchService, StarkSearchState } from "@nationalbankbelgium/stark-ui"; +import { HeroMovie, HeroMovieSearchCriteria } from "../entities"; +import { DemoGenericSearchState, selectDemoGenericSearch } from "../reducers"; +import { Store, select } from "@ngrx/store"; +import { + DemoGenericSearchHasSearched, + DemoGenericSearchHasSearchedReset, + DemoGenericSearchRemoveCriteria, + DemoGenericSearchSetCriteria +} from "../actions"; + +const MOVIES: HeroMovie[] = [ + { + uuid: "92aa85a2-8dd6-471c-9619-c723aae0c1af", + year: 2018, + hero: "Black Panther", + movie: "Black Panther" + }, + { + uuid: "df56f965-504d-48c0-b248-5c8b4c69cfc8", + year: 2017, + hero: "Wonder Woman", + movie: "Wonder Woman" + }, + { + uuid: "98115c72-c91d-4eaa-bba9-c63ec2e20bb0", + year: 2015, + hero: "Ant-Man", + movie: "Ant-Man" + }, + { + uuid: "1f803cfd-e40e-4d7b-b1f3-a932e5db7e23", + year: 2008, + hero: "Iron Man", + movie: "Iron Man" + }, + { + uuid: "147429b2-4054-43f2-bced-2f745a7a42fe", + year: 2008, + hero: "Batman", + movie: "The Dark Knight" + }, + { + uuid: "35a352df-cda6-430e-9a50-aff765df368b", + year: 1989, + hero: "Batman", + movie: "Batman" + }, + { + uuid: "973b6a41-193b-46a4-bd6b-2961808a416b", + year: 2010, + hero: "Iron Man", + movie: "Iron Man 2" + }, + { + uuid: "b8503839-66b0-4041-bc03-1a580b086cd5", + year: 2013, + hero: "Iron Man", + movie: "Iron Man 3" + } +]; + +@Injectable() +export class DemoGenericService implements StarkGenericSearchService { + public constructor(private store: Store) {} + + public getSearchState(): Observable> { + return this.store.pipe(select(selectDemoGenericSearch)); + } + + public resetSearchState(): void { + this.store.dispatch(new DemoGenericSearchRemoveCriteria()); + this.store.dispatch(new DemoGenericSearchHasSearchedReset()); + } + + public search(criteria: HeroMovieSearchCriteria): Observable { + this.store.dispatch(new DemoGenericSearchSetCriteria(criteria)); + this.store.dispatch(new DemoGenericSearchHasSearched()); + + return of(MOVIES).pipe( + // The delay is important to show the progress-indicator during the search process. + delay(1000), + map((genericObjects: HeroMovie[]) => { + return genericObjects.filter((genericObject: HeroMovie) => + criteria.year ? genericObject.year.toString().match(new RegExp(criteria.year, "gi")) : true + ); + }), + map((genericObjects: HeroMovie[]) => { + return genericObjects.filter((genericObject: HeroMovie) => + criteria.hero ? genericObject.hero.match(new RegExp(criteria.hero, "gi")) : true + ); + }), + map((genericObjects: HeroMovie[]) => { + return genericObjects.filter((genericObject: HeroMovie) => + criteria.movie ? genericObject.movie.match(new RegExp(criteria.movie, "gi")) : true + ); + }) + ); + } + + public getHeroes(): Observable { + const heroes: string[] = []; + + for (const genericObject of MOVIES) { + if (!heroes.includes(genericObject.hero)) { + heroes.push(genericObject.hero); + } + } + + return of(heroes.sort()); + } + + public getMovies(): Observable { + const movies: string[] = []; + + for (const genericObject of MOVIES) { + if (!movies.includes(genericObject.movie)) { + movies.push(genericObject.movie); + } + } + + return of(movies.sort()); + } + + public getYears(): Observable { + const years: number[] = []; + + for (const genericObject of MOVIES) { + if (!years.includes(genericObject.year)) { + years.push(genericObject.year); + } + } + + // tslint:disable-next-line:no-alphabetical-sort + return of(years.sort()); + } +} diff --git a/showcase/src/app/demo-ui/pages/generic-search/services/index.ts b/showcase/src/app/demo-ui/pages/generic-search/services/index.ts new file mode 100644 index 0000000000..0f11a74c60 --- /dev/null +++ b/showcase/src/app/demo-ui/pages/generic-search/services/index.ts @@ -0,0 +1 @@ +export * from "./demo-generic.service"; diff --git a/showcase/src/app/demo-ui/pages/index.ts b/showcase/src/app/demo-ui/pages/index.ts index 4920bce123..ea113b74f9 100644 --- a/showcase/src/app/demo-ui/pages/index.ts +++ b/showcase/src/app/demo-ui/pages/index.ts @@ -6,6 +6,7 @@ export * from "./date-picker"; export * from "./date-range-picker"; export * from "./dropdown"; export * from "./footer"; +export * from "./generic-search"; export * from "./keyboard-directives"; export * from "./language-selector"; export * from "./logout"; diff --git a/showcase/src/app/demo-ui/routes.ts b/showcase/src/app/demo-ui/routes.ts index da41578b29..97fc7aca80 100644 --- a/showcase/src/app/demo-ui/routes.ts +++ b/showcase/src/app/demo-ui/routes.ts @@ -8,6 +8,7 @@ import { DemoDateRangePickerPageComponent, DemoDropdownPageComponent, DemoFooterPageComponent, + DemoGenericSearchPageComponent, DemoKeyboardDirectivesPageComponent, DemoLanguageSelectorPageComponent, DemoLogoutPageComponent, @@ -82,6 +83,11 @@ export const DEMO_STATES: Ng2StateDeclaration[] = [ }, views: { "@": { component: DemoDropdownPageComponent } } }, + { + name: "demo-ui.generic-search", + url: "/generic-search", + views: { "@": { component: DemoGenericSearchPageComponent } } + }, { name: "demo-ui.stark-footer", url: "/stark-footer", diff --git a/showcase/src/app/shared/components/reference-block/reference-block.component.html b/showcase/src/app/shared/components/reference-block/reference-block.component.html index 888ba2f1e2..634539c610 100644 --- a/showcase/src/app/shared/components/reference-block/reference-block.component.html +++ b/showcase/src/app/shared/components/reference-block/reference-block.component.html @@ -3,7 +3,7 @@

Reference

diff --git a/showcase/src/app/shared/components/reference-block/reference-block.component.spec.ts b/showcase/src/app/shared/components/reference-block/reference-block.component.spec.ts index f6bbeb2931..4637f54a15 100644 --- a/showcase/src/app/shared/components/reference-block/reference-block.component.spec.ts +++ b/showcase/src/app/shared/components/reference-block/reference-block.component.spec.ts @@ -2,6 +2,7 @@ import { DebugElement, NO_ERRORS_SCHEMA } from "@angular/core"; import { async, ComponentFixture, TestBed } from "@angular/core/testing"; import { STARK_LOGGING_SERVICE } from "@nationalbankbelgium/stark-core"; import { MockStarkLoggingService } from "@nationalbankbelgium/stark-core/testing"; +import { TranslateModule } from "@ngx-translate/core"; import { ReferenceBlockComponent } from "./reference-block.component"; import { ReferenceLink } from "./reference-link.intf"; @@ -28,7 +29,7 @@ describe("ReferenceBlockComponent", () => { beforeEach(async(() => { return TestBed.configureTestingModule({ declarations: [ReferenceBlockComponent], - imports: [], + imports: [TranslateModule.forRoot()], providers: [{ provide: STARK_LOGGING_SERVICE, useValue: new MockStarkLoggingService() }], schemas: [NO_ERRORS_SCHEMA] // tells the Angular compiler to ignore unrecognized elements and attributes: mat-icon }).compileComponents(); diff --git a/showcase/src/assets/examples/generic-search/demo-generic-search-page.component.html b/showcase/src/assets/examples/generic-search/demo-generic-search-page.component.html new file mode 100644 index 0000000000..6c6e41a505 --- /dev/null +++ b/showcase/src/assets/examples/generic-search/demo-generic-search-page.component.html @@ -0,0 +1,20 @@ +

SHOWCASE.DEMO.GENERIC_SEARCH.TITLE

+
+

SHOWCASE.DEMO.SHARED.EXAMPLE_VIEWER_LIST

+ + + + + + +
diff --git a/showcase/src/assets/examples/generic-search/demo-generic-search-page.component.ts b/showcase/src/assets/examples/generic-search/demo-generic-search-page.component.ts new file mode 100644 index 0000000000..a7ba41441c --- /dev/null +++ b/showcase/src/assets/examples/generic-search/demo-generic-search-page.component.ts @@ -0,0 +1,81 @@ +import { Component, Inject, OnDestroy, OnInit } from "@angular/core"; +import { STARK_LOGGING_SERVICE, StarkLoggingService } from "@nationalbankbelgium/stark-core"; +import { + AbstractStarkSearchComponent, + STARK_PROGRESS_INDICATOR_SERVICE, + StarkPaginationConfig, + StarkProgressIndicatorService, + StarkTableColumnProperties +} from "@nationalbankbelgium/stark-ui"; +import { HeroMovie, HeroMovieSearchCriteria } from "./entities"; +import { DemoGenericService } from "./services"; + +@Component({ + selector: "demo-generic-search", + templateUrl: "./demo-generic-search-page.component.html" +}) +export class DemoGenericSearchPageComponent extends AbstractStarkSearchComponent + implements OnInit, OnDestroy { + public columnsProperties: StarkTableColumnProperties[]; + public searchResults: HeroMovie[]; + public paginationConfig: StarkPaginationConfig; + + public constructor( + @Inject(STARK_LOGGING_SERVICE) logger: StarkLoggingService, + demoGenericService: DemoGenericService, + @Inject(STARK_PROGRESS_INDICATOR_SERVICE) progressService: StarkProgressIndicatorService + ) { + super(demoGenericService, logger, progressService); + + this.progressIndicatorConfig.topic = "demo-generic-search"; // Set the progress topic to make the progressService working + this.performSearchOnInit = true; // Turn on automatic search (last search criteria) + this.preserveLatestResults = true; // Keep a reference to the latest results in the latestResults variable + } + + /** + * Component lifecycle hook + */ + public ngOnInit(): void { + super.ngOnInit(); + + this.results$.subscribe((genericObjects: HeroMovie[]) => (this.searchResults = genericObjects)); + + this.columnsProperties = [ + { + name: "hero", + label: "Hero", + isFilterable: true, + isSortable: true + }, + { + name: "movie", + label: "Movie", + isFilterable: true, + isSortable: true + }, + { + name: "year", + label: "Year", + isFilterable: true, + isSortable: true + } + ]; + + this.paginationConfig = { + isExtended: false, + itemsPerPage: 10, + itemsPerPageOptions: [10, 20, 50], + itemsPerPageIsPresent: true, + page: 1, + pageNavIsPresent: true, + pageInputIsPresent: true + }; + } + + /** + * Component lifecycle hook + */ + public ngOnDestroy(): void { + super.ngOnDestroy(); + } +} diff --git a/showcase/src/assets/translations/en.json b/showcase/src/assets/translations/en.json index 5fa2ce8433..551703602a 100644 --- a/showcase/src/assets/translations/en.json +++ b/showcase/src/assets/translations/en.json @@ -82,6 +82,18 @@ "INPUT2": "helpPageUrl contains the url to the help page.", "INPUT3": "The included HTML elements will be copied inside the footer as the leftmost element." }, + "GENERIC_SEARCH": { + "ACTIONS": "Actions to implement for the Generic Search", + "BASIC_IMPLEMENTATION": "Basic implementation of Generic Search", + "EXPLANATION_ABOUT_DOC_ON_GITHUB": "How to use the Stark Generic Search component", + "GETTING_STARTED": "Getting started", + "REDUCERS": "Reducers to implement for the Generic Search", + "SEARCH_RESULTS": "Search results", + "SERVICE_INTERFACE": "Interface of the service needed for the Generic Search", + "SERVICE_IMPLEMENTATION": "Implementation of the service needed for the Generic Search", + "TITLE": "Stark Generic Search", + "TOGGLE": "Toggle search" + }, "KEYBOARD_DIRECTIVES": { "ON_ENTER_KEY": { "DESCRIPTION": "Type some value in the inputs and then press Enter in any of them to trigger the callback function", diff --git a/showcase/src/assets/translations/fr.json b/showcase/src/assets/translations/fr.json index 72742c2b34..8ebde899eb 100644 --- a/showcase/src/assets/translations/fr.json +++ b/showcase/src/assets/translations/fr.json @@ -82,6 +82,18 @@ "INPUT2": "helpPageUrl contient l'url de la page d'aide.", "INPUT3": "Les éléments HTML inclus seront copiés dans le pied de page et affichés à gauche des autres éléments." }, + "GENERIC_SEARCH": { + "ACTIONS": "Actions to implement for the Generic Search", + "BASIC_IMPLEMENTATION": "Basic implementation of Generic Search", + "EXPLANATION_ABOUT_DOC_ON_GITHUB": "How to use the Stark Generic Search component", + "GETTING_STARTED": "Getting started", + "REDUCERS": "Reducers to implement for the Generic Search", + "SEARCH_RESULTS": "Search results", + "SERVICE_INTERFACE": "Interface of the service needed for the Generic Search", + "SERVICE_IMPLEMENTATION": "Implementation of the service needed for the Generic Search", + "TITLE": "Stark Generic Search", + "TOGGLE": "Toggle search" + }, "KEYBOARD_DIRECTIVES": { "ON_ENTER_KEY": { "DESCRIPTION": "Tapez une valeur dans les entrées, puis appuyez sur Entrée dans l'un d'eux pour déclencher la fonction callback", diff --git a/showcase/src/assets/translations/nl.json b/showcase/src/assets/translations/nl.json index 50de144dd6..8caceda189 100644 --- a/showcase/src/assets/translations/nl.json +++ b/showcase/src/assets/translations/nl.json @@ -82,6 +82,18 @@ "INPUT2": "helpPageUrl contains the url to the help page.", "INPUT3": "The included HTML elements will be copied inside the footer as the leftmost element." }, + "GENERIC_SEARCH": { + "ACTIONS": "Actions to implement for the Generic Search", + "BASIC_IMPLEMENTATION": "Basic implementation of Generic Search", + "EXPLANATION_ABOUT_DOC_ON_GITHUB": "How to use the Stark Generic Search component", + "GETTING_STARTED": "Getting started", + "REDUCERS": "Reducers to implement for the Generic Search", + "SEARCH_RESULTS": "Search results", + "SERVICE_INTERFACE": "Interface of the service needed for the Generic Search", + "SERVICE_IMPLEMENTATION": "Implementation of the service needed for the Generic Search", + "TITLE": "Stark Generic Search", + "TOGGLE": "Toggle search" + }, "KEYBOARD_DIRECTIVES": { "ON_ENTER_KEY": { "DESCRIPTION": "Typ een waarde in de invulvelden en druk vervolgens op Enter om de callback functie te activeren",