diff --git a/angular.json b/angular.json index e4522aca62..9a6a8eba75 100644 --- a/angular.json +++ b/angular.json @@ -2301,6 +2301,43 @@ } } } + }, + "search-docs": { + "projectType": "library", + "root": "libs/search-docs", + "sourceRoot": "libs/search-docs", + "prefix": "@daffodil", + "architect": { + "build": { + "builder": "@angular-devkit/build-angular:ng-packagr", + "options": { + "tsConfig": "libs/search-docs/tsconfig.lib.json", + "project": "libs/search-docs/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "libs/search-docs/tsconfig.lib.prod.json" + } + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "main": "libs/search-docs/test.ts", + "tsConfig": "libs/search-docs/tsconfig.spec.json", + "karmaConfig": "libs/search-docs/karma.conf.js" + } + }, + "lint": { + "builder": "@angular-eslint/builder:lint", + "options": { + "lintFilePatterns": [ + "libs/search-docs/**/*.ts", + "libs/search-docs/**/*.html" + ] + } + } + } } }, "schematics": { diff --git a/apps/daffio/package.json b/apps/daffio/package.json index ce9ac3588a..8d8639a722 100644 --- a/apps/daffio/package.json +++ b/apps/daffio/package.json @@ -65,9 +65,10 @@ "@daffodil/design": "0.0.0-PLACEHOLDER", "@daffodil/design-examples": "0.0.0-PLACEHOLDER", "@daffodil/docs": "0.0.0-PLACEHOLDER", - "@daffodil/docs-utils": "0.0.0-PLACEHOLDER", "@daffodil/router": "0.0.0-PLACEHOLDER", "@daffodil/seo": "0.0.0-PLACEHOLDER", + "@daffodil/search": "0.0.0-PLACEHOLDER", + "@daffodil/search-docs": "0.0.0-PLACEHOLDER", "@daffodil/tools-dgeni": "0.0.0-PLACEHOLDER", "@daffodil/storefront": "0.0.0-PLACEHOLDER", "@ngrx/component": "0.0.0-PLACEHOLDER" diff --git a/apps/daffio/src/app/app.config.ts b/apps/daffio/src/app/app.config.ts index f2641cdc1e..b10f037810 100644 --- a/apps/daffio/src/app/app.config.ts +++ b/apps/daffio/src/app/app.config.ts @@ -27,6 +27,8 @@ import { StoreDevtoolsModule } from '@ngrx/store-devtools'; import { DAFF_THEME_INITIALIZER } from '@daffodil/design'; import { provideDaffRouterDataServiceConfig } from '@daffodil/router'; +import { DaffSearchIncrementalStateModule } from '@daffodil/search/state'; +import { DaffSearchDocsStateModule } from '@daffodil/search-docs/state'; import { provideDaffSeoRouterSchema } from '@daffodil/seo/router'; import { appRoutes } from './app.routes'; @@ -34,6 +36,8 @@ import { environment } from '../environments/environment'; import { daffioRouterDataServiceConfig } from './core/router/data-service-config'; import { provideScrollOffset } from './core/scrolling/provide-scroll-offset'; import { provideDaffioSidebarFeature } from './core/sidebar/provider'; +import { provideDaffioDocsSearchStoreResult } from './docs/search/state/provider'; +import { provideDaffioAlgolia } from './drivers/algolia.provider'; export const appConfig: ApplicationConfig = { providers: [ @@ -59,6 +63,8 @@ export const appConfig: ApplicationConfig = { connectInZone: true, }), ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production }), + DaffSearchDocsStateModule, + DaffSearchIncrementalStateModule.withConfig(), ), provideRouter( appRoutes, @@ -81,5 +87,7 @@ export const appConfig: ApplicationConfig = { provideDaffRouterDataServiceConfig(daffioRouterDataServiceConfig), provideDaffioSidebarFeature(), provideScrollOffset(), + provideDaffioAlgolia(), + provideDaffioDocsSearchStoreResult(), ], }; diff --git a/apps/daffio/src/app/core/header/components/header/header.component.html b/apps/daffio/src/app/core/header/components/header/header.component.html index 221bfaba5a..ced3742ec0 100644 --- a/apps/daffio/src/app/core/header/components/header/header.component.html +++ b/apps/daffio/src/app/core/header/components/header/header.component.html @@ -7,15 +7,14 @@ -
-
- -
-
- -
-
- -
+
+ + + +
+
+ + +
diff --git a/apps/daffio/src/app/core/header/components/header/header.component.scss b/apps/daffio/src/app/core/header/components/header/header.component.scss index 802c0b72a4..c1d0353be1 100644 --- a/apps/daffio/src/app/core/header/components/header/header.component.scss +++ b/apps/daffio/src/app/core/header/components/header/header.component.scss @@ -21,7 +21,7 @@ color: currentColor; font-size: daff.$font-size-base; font-weight: 500; - line-height: 64px; + line-height: 4rem; padding: 0 1rem; position: relative; text-decoration: none; @@ -36,31 +36,34 @@ } &__left, - &__right { + &__desktop-right { display: flex; align-items: center; } &__left { - gap: 32px; + gap: 2rem; + width: 100%; } - &__theme-toggle { - margin-right: 0.25rem; + &__desktop-right { + display: none; @include daff.breakpoint(big-tablet) { - margin-right: 0; + display: flex; + gap: 0.5rem; } } &__menu { + display: none; margin: 0; padding: 0; @include daff.breakpoint(big-tablet) { display: flex; align-items: center; - gap: 16px; + gap: 1rem; } } @@ -74,14 +77,14 @@ } &__logo { - width: 128px; + width: 8rem; @include daff.breakpoint(mobile) { - width: 160px; + width: 10rem; } } - &__bars { + &__mobile-right { display: flex; margin-right: -0.8125rem; diff --git a/apps/daffio/src/app/core/nav/docs/docs.component.html b/apps/daffio/src/app/core/nav/docs/docs.component.html index 1605e58408..dd142d0fc6 100644 --- a/apps/daffio/src/app/core/nav/docs/docs.component.html +++ b/apps/daffio/src/app/core/nav/docs/docs.component.html @@ -1,8 +1,8 @@ +@let isBigTablet = isBigTablet$ | async; - @for (link of links$ | async; track $index) { @if (isComponent(link)) { @@ -12,7 +12,17 @@ {{link.title}} } } - +} @else { + +} \ No newline at end of file diff --git a/apps/daffio/src/app/docs/search/components/search-button/search-button.component.scss b/apps/daffio/src/app/docs/search/components/search-button/search-button.component.scss new file mode 100644 index 0000000000..f6f7c2814b --- /dev/null +++ b/apps/daffio/src/app/docs/search/components/search-button/search-button.component.scss @@ -0,0 +1,26 @@ +@use 'utilities' as daff; + +.daffio-docs-search-button { + @include daff.clickable(); + display: flex; + align-items: center; + gap: 0.75rem; + appearance: none; + border: none; + border-radius: 0.25rem; + font-size: daff.$font-size-sm; + padding: 0.75rem; + width: 16rem; + + &__kbd { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 0.25rem; + font-size: 0.75rem; + line-height: 1rem; + height: 1rem; + width: 1rem; + box-sizing: border-box; + } +} diff --git a/apps/daffio/src/app/docs/search/components/search-button/search-button.component.ts b/apps/daffio/src/app/docs/search/components/search-button/search-button.component.ts new file mode 100644 index 0000000000..ee8d03fe9a --- /dev/null +++ b/apps/daffio/src/app/docs/search/components/search-button/search-button.component.ts @@ -0,0 +1,86 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + HostListener, + Input, + OnInit, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + NavigationEnd, + Router, +} from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { faSearch } from '@fortawesome/free-solid-svg-icons'; +import { filter } from 'rxjs'; + +import { DaffIconButtonComponent } from '@daffodil/design/button'; +import { + DaffModalRef, + DaffModalService, +} from '@daffodil/design/modal'; + +import { DaffioDocsSearchModalComponent } from '../search-modal/search-modal.component'; + +@Component({ + selector: 'daffio-docs-search-button', + templateUrl: './search-button.component.html', + styleUrl: './search-button.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FaIconComponent, + DaffIconButtonComponent, + ], + providers: [ + DaffModalService, + ], +}) + +export class DaffioDocsSearchButtonComponent implements OnInit { + faSearch = faSearch; + + modal: DaffModalRef | undefined; + + @Input() icon = false; + + constructor( + private modalService: DaffModalService, + private destroyRef: DestroyRef, + private router: Router, + ) {} + + ngOnInit(): void { + this.router.events.pipe( + takeUntilDestroyed(this.destroyRef), + filter((evt) => evt instanceof NavigationEnd), + ).subscribe(() => { + this.modal?.close(); + }); + } + + showModal() { + this.modal = this.modalService.open( + DaffioDocsSearchModalComponent, + { + ariaLabelledBy: 'Search docs', + position: { + vertical: 'top', + }, + }, + ); + this.modal.afterClosed.pipe( + takeUntilDestroyed(this.destroyRef), + ).subscribe(() => { + this.modal = undefined; + }); + } + + @HostListener('document:keydown', ['$event']) + handleKeydown(event: KeyboardEvent) { + if (event.key === '/' && !this.modal) { + event.preventDefault(); + this.showModal(); + } + } +} diff --git a/apps/daffio/src/app/docs/search/components/search-field/search-field-theme.scss b/apps/daffio/src/app/docs/search/components/search-field/search-field-theme.scss new file mode 100644 index 0000000000..f23306bfd5 --- /dev/null +++ b/apps/daffio/src/app/docs/search/components/search-field/search-field-theme.scss @@ -0,0 +1,12 @@ +@use 'theme' as *; + +@mixin daffio-docs-search-field-theme($theme) { + $neutral: daff-get-palette($theme, neutral); + $base: daff-get-base-color($theme, base); + $base-contrast: daff-get-base-color($theme, base-contrast); + $mode: daff-get-theme-mode($theme); + + .daffio-docs-search-field { + border-bottom: 1px solid daff-color($neutral, 20); + } +} diff --git a/apps/daffio/src/app/docs/search/components/search-field/search-field.component.html b/apps/daffio/src/app/docs/search/components/search-field/search-field.component.html new file mode 100644 index 0000000000..22dda38bd1 --- /dev/null +++ b/apps/daffio/src/app/docs/search/components/search-field/search-field.component.html @@ -0,0 +1,9 @@ + +@if (inputValue().value) { + +} \ No newline at end of file diff --git a/apps/daffio/src/app/docs/search/components/search-field/search-field.component.scss b/apps/daffio/src/app/docs/search/components/search-field/search-field.component.scss new file mode 100644 index 0000000000..6ba5de5129 --- /dev/null +++ b/apps/daffio/src/app/docs/search/components/search-field/search-field.component.scss @@ -0,0 +1,25 @@ +:host { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.daffio-docs-search-field { + &__input { + appearance: none; + background: none; + border: none; + color: currentColor; + font-size: 1rem; + line-height: 1.5rem; + flex-grow: 1; + outline: none; + margin: 0; + padding: 1.25rem 1rem; + + &::-webkit-search-cancel-button { + -webkit-appearance: none; + appearance: none; + } + } +} diff --git a/apps/daffio/src/app/docs/search/components/search-field/search-field.component.ts b/apps/daffio/src/app/docs/search/components/search-field/search-field.component.ts new file mode 100644 index 0000000000..50ce45962e --- /dev/null +++ b/apps/daffio/src/app/docs/search/components/search-field/search-field.component.ts @@ -0,0 +1,38 @@ +import { + ChangeDetectionStrategy, + Component, + input, +} from '@angular/core'; +import { + FormControl, + ReactiveFormsModule, +} from '@angular/forms'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { faTimes } from '@fortawesome/free-solid-svg-icons'; + +import { DaffIconButtonComponent } from '@daffodil/design/button'; +import { DAFF_FORM_FIELD_COMPONENTS } from '@daffodil/design/form-field'; + +@Component({ + selector: 'daffio-docs-search-field', + templateUrl: './search-field.component.html', + styleUrl: './search-field.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FaIconComponent, + DaffIconButtonComponent, + DAFF_FORM_FIELD_COMPONENTS, + ReactiveFormsModule, + ], + host: { + class: 'daffio-docs-search-field', + }, +}) +export class DaffioDocsSearchFieldComponent { + readonly faTimes = faTimes; + readonly inputValue = input.required>(); + + clearField() { + this.inputValue().patchValue(''); + } +} diff --git a/apps/daffio/src/app/docs/search/components/search-footer/search-footer-theme.scss b/apps/daffio/src/app/docs/search/components/search-footer/search-footer-theme.scss new file mode 100644 index 0000000000..2e807463bf --- /dev/null +++ b/apps/daffio/src/app/docs/search/components/search-footer/search-footer-theme.scss @@ -0,0 +1,34 @@ +@use 'theme' as *; + +@mixin daffio-docs-search-footer-theme($theme) { + $neutral: daff-get-palette($theme, neutral); + $base: daff-get-base-color($theme, base); + $base-contrast: daff-get-base-color($theme, base-contrast); + $mode: daff-get-theme-mode($theme); + + .daffio-docs-search-footer { + @include light($mode) { + border-top: 1px solid daff-color($neutral, 20); + + &__command-keys, + &__algolia { + color: daff-color($neutral); + } + + &__kbd { + border: 1px solid daff-color($neutral); + } + } + + @include dark($mode) { + &__command-keys, + &__algolia { + color: daff-color($neutral, 40); + } + + &__kbd { + border: 1px solid daff-color($neutral, 40); + } + } + } +} diff --git a/apps/daffio/src/app/docs/search/components/search-footer/search-footer.component.html b/apps/daffio/src/app/docs/search/components/search-footer/search-footer.component.html new file mode 100644 index 0000000000..1bd3006e54 --- /dev/null +++ b/apps/daffio/src/app/docs/search/components/search-footer/search-footer.component.html @@ -0,0 +1,22 @@ + + \ No newline at end of file diff --git a/apps/daffio/src/app/docs/search/components/search-footer/search-footer.component.scss b/apps/daffio/src/app/docs/search/components/search-footer/search-footer.component.scss new file mode 100644 index 0000000000..07b64f2ced --- /dev/null +++ b/apps/daffio/src/app/docs/search/components/search-footer/search-footer.component.scss @@ -0,0 +1,54 @@ +@use 'utilities' as daff; + +:host { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; +} + +.daffio-docs-search-footer { + &__commands { + display: none; + + @include daff.breakpoint(tablet) { + display: flex; + gap: 1rem; + } + } + + &__command-keys { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: daff.$font-size-sm; + line-height: 1.25rem; + } + + &__enter-key { + transform: rotate(90deg); + } + + &__kbd { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 0.25rem; + font-size: 0.75rem; + line-height: 1rem; + padding: 0.25rem; + box-sizing: border-box; + } + + &__algolia { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + line-height: 1rem; + } + + &__algolia-image { + width: 62px; + } +} diff --git a/apps/daffio/src/app/docs/search/components/search-footer/search-footer.component.ts b/apps/daffio/src/app/docs/search/components/search-footer/search-footer.component.ts new file mode 100644 index 0000000000..985656ef25 --- /dev/null +++ b/apps/daffio/src/app/docs/search/components/search-footer/search-footer.component.ts @@ -0,0 +1,32 @@ +import { + ChangeDetectionStrategy, + Component, + HostBinding, +} from '@angular/core'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { + faArrowDown, + faArrowTurnDown, + faArrowUp, +} from '@fortawesome/free-solid-svg-icons'; + +import { DAFF_IMAGE_COMPONENTS } from '@daffodil/design/image'; + +@Component({ + selector: 'daffio-docs-search-footer', + templateUrl: './search-footer.component.html', + styleUrl: './search-footer.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FaIconComponent, + DAFF_IMAGE_COMPONENTS, + ], +}) + +export class DaffioDocsSearchFooterComponent { + faArrowUp = faArrowUp; + faArrowDown = faArrowDown; + faArrowTurnDown = faArrowTurnDown; + + @HostBinding('class.daffio-docs-search-footer') private class = true; +} diff --git a/apps/daffio/src/app/docs/search/components/search-modal/search-modal-theme.scss b/apps/daffio/src/app/docs/search/components/search-modal/search-modal-theme.scss new file mode 100644 index 0000000000..65ad59f2cd --- /dev/null +++ b/apps/daffio/src/app/docs/search/components/search-modal/search-modal-theme.scss @@ -0,0 +1,69 @@ +@use 'theme' as *; + +@mixin daffio-docs-search-modal-theme($theme) { + $neutral: daff-get-palette($theme, neutral); + $primary: daff-get-palette($theme, primary); + $base: daff-get-base-color($theme, base); + $base-contrast: daff-get-base-color($theme, base-contrast); + $mode: daff-get-theme-mode($theme); + + @include light($mode) { + .daffio-docs-search-results { + &__item { + &.active { + background: daff-color($neutral, 10); + } + + a { + &:hover { + background: daff-color($neutral, 10); + } + } + } + + &__category { + color: daff-color($neutral, 70); + } + + &__package { + background: rgba(daff-color($primary, 60), 0.05); + color: daff-color($primary, 90); + } + + &__start-screen, + &__no-results { + color: daff-color($neutral); + } + } + } + + @include dark($mode) { + .daffio-docs-search-results { + &__item { + &.active { + background: daff-color($neutral, 90); + } + + a { + &:hover { + background: daff-color($neutral, 90); + } + } + } + + &__category { + color: daff-color($neutral, 30); + } + + &__package { + background: rgba(daff-color($primary, 60), 0.2); + color: daff-color($primary, 10); + } + + &__start-screen, + &__no-results { + color: daff-color($neutral); + } + } + } +} diff --git a/apps/daffio/src/app/docs/search/components/search-modal/search-modal.component.html b/apps/daffio/src/app/docs/search/components/search-modal/search-modal.component.html new file mode 100644 index 0000000000..0b205d8456 --- /dev/null +++ b/apps/daffio/src/app/docs/search/components/search-modal/search-modal.component.html @@ -0,0 +1,70 @@ + + +@let recentQueries = recentQueries$ | async; +@let results = docsResults$ | async; +@let loading = loading$ | async; + +@if (recentQueries?.length > 0 && !results?.length && !loading) { + +} + +@if (results?.length > 0 || loading) { + +} + +@if (formControl.value && !results?.length && !loading) { +
+
+ No results found +
+
+} + +@if (!formControl.value && !recentQueries?.length && !results?.length && !loading) { +
+
+ Start typing to see results +
+
+} + + \ No newline at end of file diff --git a/apps/daffio/src/app/docs/search/components/search-modal/search-modal.component.scss b/apps/daffio/src/app/docs/search/components/search-modal/search-modal.component.scss new file mode 100644 index 0000000000..1c43281bc8 --- /dev/null +++ b/apps/daffio/src/app/docs/search/components/search-modal/search-modal.component.scss @@ -0,0 +1,97 @@ +@use 'utilities' as daff; + +:host { + display: block; + width: 90vw; + + @include daff.breakpoint(tablet) { + width: 60vw; + } + + @include daff.breakpoint(small-laptop) { + width: 50vw; + } +} + +.daffio-docs-search-history { + display: block; + + &__title { + font-size: daff.$font-size-sm; + font-weight: 500; + line-height: 1rem; + margin: 1rem 0 0 1rem; + } + + &__list { + display: block; + list-style: none; + margin: 0; + padding: 1rem 0; + max-height: 50vh; + overflow-y: auto; + } +} + +.daffio-docs-search-results { + display: block; + list-style: none; + margin: 0; + padding: 1rem 0; + max-height: 50vh; + overflow-y: auto; + + &__item { + margin: 0; + padding: 0; + + a { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin: 0; + padding: 1rem; + text-decoration: none; + } + } + + &__result-wrapper { + display: flex; + align-items: center; + gap: 0.5rem; + + @include daff.breakpoint(tablet) { + gap: 1rem; + } + } + + &__result-package { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + } + + &__result { + font-weight: 500; + word-break: break-word; + } + + &__package { + font-family: daff.$monospace-font-family; + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + } + + &__category { + font-size: daff.$font-size-sm; + line-height: 1rem; + } + + &__start-screen, + &__no-results { + padding: 0 1rem; + } +} diff --git a/apps/daffio/src/app/docs/search/components/search-modal/search-modal.component.ts b/apps/daffio/src/app/docs/search/components/search-modal/search-modal.component.ts new file mode 100644 index 0000000000..6f2194c707 --- /dev/null +++ b/apps/daffio/src/app/docs/search/components/search-modal/search-modal.component.ts @@ -0,0 +1,148 @@ +import { ActiveDescendantKeyManager } from '@angular/cdk/a11y'; +import { AsyncPipe } from '@angular/common'; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + DestroyRef, + OnInit, + QueryList, + ViewChildren, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormControl } from '@angular/forms'; +import { RouterLink } from '@angular/router'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { faClockRotateLeft } from '@fortawesome/free-solid-svg-icons'; +import { + select, + Store, +} from '@ngrx/store'; +import { + combineLatest, + map, + Observable, + of, + switchMap, +} from 'rxjs'; + +import { DaffDocsFacade } from '@daffodil/docs/state'; +import { + DaffSearchIncremental, + DaffSearchIncrementalFacade, + DaffSearchPageFacade, +} from '@daffodil/search/state'; +import { + DAFF_SEARCH_DOCS_RESULT_KIND, + DaffSearchDocsResult, +} from '@daffodil/search-docs'; + +import { DAFF_DOCS_SEARCH_RESULT_ICONS } from '../../constants/result-icons.const'; +import { DaffioDocsSearchResultItemDirective } from '../../directives/search-result-item/search-result-item.directive'; +import { DaffDocsSearchStoreResult } from '../../state/actions'; +import { DaffioDocsSearchStateFeatureSlice } from '../../state/reducers'; +import { selectDaffioDocsSearchRecentResultsSelector } from '../../state/selectors'; +import { DaffioDocsSearchFieldComponent } from '../search-field/search-field.component'; +import { DaffioDocsSearchFooterComponent } from '../search-footer/search-footer.component'; + +@Component({ + selector: 'daffio-docs-search-modal', + templateUrl: './search-modal.component.html', + styleUrl: './search-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'daffio-docs-search-modal', + '(keydown)': 'onKeydown($event)', + }, + imports: [ + DaffioDocsSearchFieldComponent, + DaffioDocsSearchResultItemDirective, + DaffioDocsSearchFooterComponent, + FaIconComponent, + AsyncPipe, + RouterLink, + ], +}) + +export class DaffioDocsSearchModalComponent implements AfterViewInit, OnInit { + readonly faClockRotateLeft = faClockRotateLeft; + readonly RESULT_ICONS = DAFF_DOCS_SEARCH_RESULT_ICONS; + + @ViewChildren(DaffioDocsSearchResultItemDirective) items: QueryList; + + recentQueries$: Observable>; + docsResults$: Observable>; + loading$: Observable; + + readonly formControl = new FormControl(''); + + private keyManager: ActiveDescendantKeyManager; + + constructor( + private incrementalFacade: DaffSearchIncrementalFacade, + private docsFacade: DaffDocsFacade, + private store: Store, + private facade: DaffSearchPageFacade, + private destroyRef: DestroyRef, + ) {} + + onClick(id: DaffSearchDocsResult['id']): void { + this.facade.dispatch(new DaffDocsSearchStoreResult(id)); + } + + ngOnInit(): void { + this.loading$ = this.incrementalFacade.loading$; + this.recentQueries$ = this.store.pipe( + select(selectDaffioDocsSearchRecentResultsSelector), + switchMap((ids) => this.docsFacade.docsEntities$.pipe( + map((entities) => ids?.map((id) => entities[id]) || []), + )), + ); + this.docsResults$ = combineLatest([ + this.incrementalFacade.searchResultIds$, + this.formControl.valueChanges, + ]).pipe( + switchMap(([ids, query]) => + query + ? this.docsFacade.docsEntities$.pipe( + map((entities) => ids[DAFF_SEARCH_DOCS_RESULT_KIND]?.map((id) => entities[id]) || []), + ) + : of([]), + ), + ); + this.formControl.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef), + ).subscribe((val) => this.onInput(val)); + } + + ngAfterViewInit() { + this.keyManager = new ActiveDescendantKeyManager(this.items).withWrap(); + + this.items.changes.subscribe(() => { + if (this.items.length > 0) { + this.keyManager.setFirstItemActive(); + } + }); + + if (this.items.length > 0) { + this.keyManager.setFirstItemActive(); + } + } + + onKeydown(event: KeyboardEvent) { + if (event.key === 'Enter') { + const activeItem = this.keyManager.activeItem; + if (activeItem) { + activeItem.navigate(); + } + } else { + this.keyManager.onKeydown(event); + } + } + + onInput(query: string) { + if (query) { + this.facade.dispatch(new DaffSearchIncremental(query)); + } + } +} diff --git a/apps/daffio/src/app/docs/search/constants/result-icons.const.ts b/apps/daffio/src/app/docs/search/constants/result-icons.const.ts new file mode 100644 index 0000000000..d81e5f4913 --- /dev/null +++ b/apps/daffio/src/app/docs/search/constants/result-icons.const.ts @@ -0,0 +1,21 @@ +import { IconDefinition } from '@fortawesome/angular-fontawesome'; +import { + faClipboard, + faFileLines, +} from '@fortawesome/free-regular-svg-icons'; +import { + faBoxOpen, + faCode, + faCubes, +} from '@fortawesome/free-solid-svg-icons'; + +import { DaffDocKind } from '@daffodil/docs-utils'; + +export const DAFF_DOCS_SEARCH_RESULT_ICONS: Record = { + [DaffDocKind.GUIDE]: faFileLines, + [DaffDocKind.EXPLANATION]: faFileLines, + [DaffDocKind.PACKAGE]: faBoxOpen, + [DaffDocKind.API]: faCode, + [DaffDocKind.EXAMPLE]: faClipboard, + [DaffDocKind.COMPONENT]: faCubes, +}; diff --git a/apps/daffio/src/app/docs/search/directives/search-result-item/search-result-item.directive.ts b/apps/daffio/src/app/docs/search/directives/search-result-item/search-result-item.directive.ts new file mode 100644 index 0000000000..c827aefa7a --- /dev/null +++ b/apps/daffio/src/app/docs/search/directives/search-result-item/search-result-item.directive.ts @@ -0,0 +1,46 @@ +import { Highlightable } from '@angular/cdk/a11y'; +import { + contentChild, + Directive, + ElementRef, + inject, + signal, +} from '@angular/core'; +import { + Router, + RouterLink, +} from '@angular/router'; + +@Directive({ + selector: '[daffioDocsSearchResultItem]', + host: { + role: 'option', + '[class.active]': '_isActive()', + }, +}) + +export class DaffioDocsSearchResultItemDirective implements Highlightable { + private _isActive = signal(false); + + constructor(private elementRef: ElementRef) {} + + routerLink = contentChild(RouterLink); + + private _router = inject(Router); + + setActiveStyles() { + this._isActive.set(true); + this.elementRef.nativeElement.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + }); + }; + + setInactiveStyles() { + this._isActive.set(false); + } + + navigate() { + this._router.navigateByUrl(this.routerLink().urlTree); + } +} diff --git a/apps/daffio/src/app/docs/search/state/actions.ts b/apps/daffio/src/app/docs/search/state/actions.ts new file mode 100644 index 0000000000..9c7b579c42 --- /dev/null +++ b/apps/daffio/src/app/docs/search/state/actions.ts @@ -0,0 +1,14 @@ +import { Action } from '@ngrx/store'; + +export enum DaffioDocsSearchActionTypes { + STORE_RESULT = '[@daffodil/daffio] Docs Search Store Result', +} + +export class DaffDocsSearchStoreResult implements Action { + readonly type = DaffioDocsSearchActionTypes.STORE_RESULT; + + constructor(public result: string) {} +} + +export type DaffioDocsSearchActions = +| DaffDocsSearchStoreResult; diff --git a/apps/daffio/src/app/docs/search/state/feature-key.const.ts b/apps/daffio/src/app/docs/search/state/feature-key.const.ts new file mode 100644 index 0000000000..11d5e9a5e8 --- /dev/null +++ b/apps/daffio/src/app/docs/search/state/feature-key.const.ts @@ -0,0 +1 @@ +export const DAFFIO_DOCS_SEARCH_STATE_FEATURE_KEY = 'daffioDocsSearch'; diff --git a/apps/daffio/src/app/docs/search/state/provider.ts b/apps/daffio/src/app/docs/search/state/provider.ts new file mode 100644 index 0000000000..c65240241a --- /dev/null +++ b/apps/daffio/src/app/docs/search/state/provider.ts @@ -0,0 +1,12 @@ +import { + importProvidersFrom, + makeEnvironmentProviders, +} from '@angular/core'; +import { StoreModule } from '@ngrx/store'; + +import { DAFFIO_DOCS_SEARCH_STATE_FEATURE_KEY } from './feature-key.const'; +import { daffioDocsSearchStoreResultReducers } from './reducers'; + +export const provideDaffioDocsSearchStoreResult = () => makeEnvironmentProviders([ + importProvidersFrom(StoreModule.forFeature(DAFFIO_DOCS_SEARCH_STATE_FEATURE_KEY, daffioDocsSearchStoreResultReducers)), +]); diff --git a/apps/daffio/src/app/docs/search/state/reducers.ts b/apps/daffio/src/app/docs/search/state/reducers.ts new file mode 100644 index 0000000000..130b768b19 --- /dev/null +++ b/apps/daffio/src/app/docs/search/state/reducers.ts @@ -0,0 +1,53 @@ +import { + ActionReducer, + combineReducers, +} from '@ngrx/store'; + + + +import { DaffSearchDocsResult } from '@daffodil/search-docs'; + +import { + DaffioDocsSearchActions, + DaffioDocsSearchActionTypes, +} from './actions'; +import { DAFFIO_DOCS_SEARCH_STATE_FEATURE_KEY } from './feature-key.const'; + +export interface DaffioDocsSearchResultsReducerState { + recent: Array; +} + +export interface DaffioDocsSearchReducersState { + results: DaffioDocsSearchResultsReducerState; +} + +export interface DaffioDocsSearchStateFeatureSlice { + [DAFFIO_DOCS_SEARCH_STATE_FEATURE_KEY]: DaffioDocsSearchReducersState; +} + +export const daffioDocsSearchResultsReducerInitialState: DaffioDocsSearchResultsReducerState = { + recent: [], +}; + +export const daffioDocsSearchResultsReducer: ActionReducer = ( + state: DaffioDocsSearchResultsReducerState = daffioDocsSearchResultsReducerInitialState, + action: DaffioDocsSearchActions, +): DaffioDocsSearchResultsReducerState => { + switch (action.type) { + case DaffioDocsSearchActionTypes.STORE_RESULT: + return { + ...state, + recent: [ + action.result, + ...state.recent, + ], + }; + + default: + return state; + } +}; + +export const daffioDocsSearchStoreResultReducers = combineReducers({ + results: daffioDocsSearchResultsReducer, +}); diff --git a/apps/daffio/src/app/docs/search/state/selectors.ts b/apps/daffio/src/app/docs/search/state/selectors.ts new file mode 100644 index 0000000000..e1e2ada48c --- /dev/null +++ b/apps/daffio/src/app/docs/search/state/selectors.ts @@ -0,0 +1,11 @@ +import { + createFeatureSelector, + createSelector, +} from '@ngrx/store'; + +import { DAFFIO_DOCS_SEARCH_STATE_FEATURE_KEY } from './feature-key.const'; +import { DaffioDocsSearchReducersState } from './reducers'; + +export const selectDaffioDocsSearchFeatureSelector = createFeatureSelector(DAFFIO_DOCS_SEARCH_STATE_FEATURE_KEY); +export const selectDaffioDocsSearchResultsFeatureSelector = createSelector(selectDaffioDocsSearchFeatureSelector, (state) => state.results); +export const selectDaffioDocsSearchRecentResultsSelector = createSelector(selectDaffioDocsSearchResultsFeatureSelector, (state) => state.recent); diff --git a/apps/daffio/src/app/drivers/algolia.provider.ts b/apps/daffio/src/app/drivers/algolia.provider.ts new file mode 100644 index 0000000000..88be524acc --- /dev/null +++ b/apps/daffio/src/app/drivers/algolia.provider.ts @@ -0,0 +1,5 @@ +import { provideAlgoliaSearchDocs } from '@daffodil/search-docs/driver/algolia'; + +import { environment } from '../../environments/environment'; + +export const provideDaffioAlgolia = () => provideAlgoliaSearchDocs(environment.algolia, () => 'docs'); diff --git a/apps/daffio/src/assets/algolia-logo.svg b/apps/daffio/src/assets/algolia-logo.svg new file mode 100644 index 0000000000..616f687187 --- /dev/null +++ b/apps/daffio/src/assets/algolia-logo.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/daffio/src/environments/environment.prod.ts b/apps/daffio/src/environments/environment.prod.ts index 830f2c3e2e..436e35ee32 100644 --- a/apps/daffio/src/environments/environment.prod.ts +++ b/apps/daffio/src/environments/environment.prod.ts @@ -3,4 +3,9 @@ import { DaffioEnvironment } from './type'; export const environment: DaffioEnvironment = { production: true, docsPath: 'browser/assets/daffio/', + algolia: { + appId: 'XZA1GON7E4', + apiKey: '9631952a5bdb34966263e40b9a4fa3c7', + indexName: 'daff_io', + }, }; diff --git a/apps/daffio/src/environments/environment.ts b/apps/daffio/src/environments/environment.ts index 21770d33cb..e631d3afa7 100644 --- a/apps/daffio/src/environments/environment.ts +++ b/apps/daffio/src/environments/environment.ts @@ -7,6 +7,11 @@ import { DaffioEnvironment } from './type'; export const environment: DaffioEnvironment = { production: false, docsPath: 'dist/apps/daffio/browser/assets/daffio/', + algolia: { + appId: 'XZA1GON7E4', + apiKey: '9631952a5bdb34966263e40b9a4fa3c7', + indexName: 'next_daff_io', + }, }; /* diff --git a/apps/daffio/src/environments/type.ts b/apps/daffio/src/environments/type.ts index 6ac3d48590..d92300acfc 100644 --- a/apps/daffio/src/environments/type.ts +++ b/apps/daffio/src/environments/type.ts @@ -1,4 +1,9 @@ export interface DaffioEnvironment { production: boolean; docsPath: string; + algolia: { + appId: string; + apiKey: string; + indexName: string; + }; } diff --git a/apps/daffio/src/scss/component-themes.scss b/apps/daffio/src/scss/component-themes.scss index 2e3098c97d..bde32457b7 100644 --- a/apps/daffio/src/scss/component-themes.scss +++ b/apps/daffio/src/scss/component-themes.scss @@ -21,6 +21,10 @@ @use '../app/docs/components/doc-viewer/doc-viewer-theme' as doc-viewer; @use '../app/docs/components/example-viewer/example-viewer-theme' as example-viewer; @use '../app/content/home/components/home-callout-sponsors/home-callout-sponsors-theme' as home-callout-sponsors; +@use '../app/docs/search/components/search-button/search-button-theme' as docs-search-button; +@use '../app/docs/search/components/search-field/search-field-theme' as docs-search-field; +@use '../app/docs/search/components/search-footer/search-footer-theme' as docs-search-footer; +@use '../app/docs/search/components/search-modal/search-modal-theme' as docs-search-modal; @mixin component-themes($theme) { @include home-hero.daffio-home-hero-theme($theme); @@ -47,4 +51,9 @@ @include api-item-label.daffio-api-item-label-theme($theme); @include doc-viewer.daffio-doc-viewer-theme($theme); @include example-viewer.daffio-docs-example-viewer-theme($theme); + + @include docs-search-button.daffio-docs-search-button-theme($theme); + @include docs-search-field.daffio-docs-search-field-theme($theme); + @include docs-search-footer.daffio-docs-search-footer-theme($theme); + @include docs-search-modal.daffio-docs-search-modal-theme($theme); } diff --git a/libs/analytics/src/lib/config/config.ts b/libs/analytics/src/lib/config/config.ts index 9901bc222d..be05420797 100644 --- a/libs/analytics/src/lib/config/config.ts +++ b/libs/analytics/src/lib/config/config.ts @@ -29,7 +29,7 @@ export const { * Provider function for {@link DaffAnalyticsConfig}. */ provider: provideDaffAnalyticsConfig, -} = createConfigInjectionToken( +} = createConfigInjectionToken( defaultConfig, 'DaffAnalyticsConfig', { providedIn: 'root' }, diff --git a/libs/auth/routing/src/config/token.ts b/libs/auth/routing/src/config/token.ts index 7476bfe49a..27c3186a4f 100644 --- a/libs/auth/routing/src/config/token.ts +++ b/libs/auth/routing/src/config/token.ts @@ -12,4 +12,4 @@ export const { * Provider function for {@link DAFF_AUTH_ROUTING_CONFIG}. */ provider: provideDaffAuthRoutingConfig, -} = createConfigInjectionToken(DAFF_AUTH_ROUTING_CONFIG_DEFAULT, 'DAFF_AUTH_ROUTING_CONFIG'); +} = createConfigInjectionToken(DAFF_AUTH_ROUTING_CONFIG_DEFAULT, 'DAFF_AUTH_ROUTING_CONFIG'); diff --git a/libs/core/src/injection-tokens/config.factory.ts b/libs/core/src/injection-tokens/config.factory.ts index 8d4cbb870f..965cde0ba5 100644 --- a/libs/core/src/injection-tokens/config.factory.ts +++ b/libs/core/src/injection-tokens/config.factory.ts @@ -8,25 +8,28 @@ import { TokenDesc, TokenOptions, } from './token-constuctor-params.type'; +import { RequiredProperties } from '../types/public_api'; /** * Creates an injection token/provider pair for a DI token that holds a configuration. * * See {@link DaffConfigInjectionToken}. */ -export const createConfigInjectionToken = ( - defaultConfig: T | InjectionToken, +export const createConfigInjectionToken = = Partial>( + defaultConfig: TDefault | InjectionToken, desc: TokenDesc, options?: Partial>, -): DaffConfigInjectionToken => { +): DaffConfigInjectionToken => { const token = new InjectionToken( desc, { - factory: () => defaultConfig instanceof InjectionToken ? inject(defaultConfig) : defaultConfig, + factory: () => null, ...options, }, ); - const provider = (config: Partial | InjectionToken>) => ({ + const provider = > & Partial>> + = Omit> & Partial>>>(config: R | InjectionToken) => ({ provide: token, useFactory: () => ({ ...(defaultConfig instanceof InjectionToken ? inject(defaultConfig) : defaultConfig), diff --git a/libs/core/src/injection-tokens/config.type.ts b/libs/core/src/injection-tokens/config.type.ts index 2211769151..5746fbc3a7 100644 --- a/libs/core/src/injection-tokens/config.type.ts +++ b/libs/core/src/injection-tokens/config.type.ts @@ -3,10 +3,12 @@ import { FactoryProvider, } from '@angular/core'; +import { RequiredProperties } from '../public_api'; + /** * A injection token to hold and provide a config value. */ -export interface DaffConfigInjectionToken { +export interface DaffConfigInjectionToken = Partial> { /** * The injection token. * Its default value is the default config passed during token creation. @@ -19,5 +21,7 @@ export interface DaffConfigInjectionToken { * with the passed config keys taking precedence. * An injection token containing a config may also be passed. */ - provider: (config: Partial | InjectionToken>) => FactoryProvider; + provider: > & Partial>> + = Omit> & Partial>>>(config: R | InjectionToken) => FactoryProvider; } diff --git a/libs/core/src/types/public_api.ts b/libs/core/src/types/public_api.ts index e76ab3d3df..51dbad6375 100644 --- a/libs/core/src/types/public_api.ts +++ b/libs/core/src/types/public_api.ts @@ -1,2 +1,3 @@ export { ID } from './id.type'; export { HTML } from './html.type'; +export { RequiredProperties } from './required-properties.type'; diff --git a/libs/core/src/types/required-properties.type.ts b/libs/core/src/types/required-properties.type.ts new file mode 100644 index 0000000000..5ddb774e7d --- /dev/null +++ b/libs/core/src/types/required-properties.type.ts @@ -0,0 +1,9 @@ +/** + * Finds the properties in `T` that are required, that is, not optional. + * Based on https://stackoverflow.com/a/53899815. + */ +export type RequiredProperties = Exclude<{ + [K in keyof T]: T extends Record + ? K + : never +}[keyof T], undefined>; diff --git a/libs/core/src/utils/group.spec.ts b/libs/core/src/utils/group.spec.ts new file mode 100644 index 0000000000..0683120e62 --- /dev/null +++ b/libs/core/src/utils/group.spec.ts @@ -0,0 +1,46 @@ +import { group } from './group'; + +interface Foo { + type: 'foo'; + foo: string; +} + +interface Bar { + type: 'bar'; + bar: string; +} + +describe('@daffodil/core | group', () => { + const values: Array = [ + { + type: 'bar', + bar: 'test', + }, + { + type: 'foo', + foo: '5', + }, + { + type: 'foo', + foo: 'asdefasdf', + }, + { + type: 'bar', + bar: 'one', + }, + { + type: 'foo', + foo: 'test', + }, + { + type: 'bar', + bar: 'dddd', + }, + ]; + + it('should group the value according to their type', () => { + const result = group(values, (val) => val.type); + expect(result.foo).toEqual(jasmine.arrayContaining(values.filter(({ type }) => type === 'foo'))); + expect(result.bar).toEqual(jasmine.arrayContaining(values.filter(({ type }) => type === 'bar'))); + }); +}); diff --git a/libs/core/src/utils/group.ts b/libs/core/src/utils/group.ts new file mode 100644 index 0000000000..45fcfa7542 --- /dev/null +++ b/libs/core/src/utils/group.ts @@ -0,0 +1,15 @@ +/** + * Groups values by the key returned by `getKey`. + */ +export const group = < + T, + R extends string | number | symbol = string | number | symbol +>(array: Array, getKey: (val: T) => R): Record> => + array.reduce((acc, val) => { + const key = getKey(val); + if (!acc[key]) { + acc[key] = []; + } + acc[key].push(val); + return acc; + }, >>{}); diff --git a/libs/core/src/utils/public_api.ts b/libs/core/src/utils/public_api.ts index d5e368d77e..4a7eac4bb9 100644 --- a/libs/core/src/utils/public_api.ts +++ b/libs/core/src/utils/public_api.ts @@ -1,9 +1,5 @@ export { collect } from './collect'; -export { daffAdd } from './long-arithmetic'; export { daffArrayToDict } from './array-to-dict'; -export { daffDivide } from './long-arithmetic'; -export { daffMultiply } from './long-arithmetic'; -export { daffSubtract } from './long-arithmetic'; export { randomSlice } from './random-slice'; export { randomSubset } from './random-subset'; export { range } from './range'; @@ -14,3 +10,4 @@ export { unique } from './unique'; export * from './long-arithmetic'; export * from './identity'; export * from './observe'; +export * from './group'; diff --git a/libs/core/state/src/actions/injectable/direct.type.ts b/libs/core/state/src/actions/injectable/direct.type.ts new file mode 100644 index 0000000000..8f96272dd0 --- /dev/null +++ b/libs/core/state/src/actions/injectable/direct.type.ts @@ -0,0 +1,11 @@ +import { Action } from '@ngrx/store'; + +/** + * An injection of an action that matches the desired payload and does not require transformation. + */ +export interface ActionDirectInjection { + /** + * The action type. + */ + type: Act['type']; +} diff --git a/libs/core/state/src/actions/injectable/factory.ts b/libs/core/state/src/actions/injectable/factory.ts new file mode 100644 index 0000000000..a69ab75afe --- /dev/null +++ b/libs/core/state/src/actions/injectable/factory.ts @@ -0,0 +1,30 @@ +import { InjectionToken } from '@angular/core'; +import { Action } from '@ngrx/store'; + +import { ActionDirectInjection } from './direct.type'; +import { ActionTransformedInjection } from './transformed.type'; +import { ActionInjection } from './type'; + +const defaultFactory = () => []; + +/** + * Creates a token provider pair for an injectable action. + */ +export const createInjectableAction = (tokenName: string) => { + const token = new InjectionToken>>( + tokenName, + { + factory: defaultFactory, + }, + ); + const provider = (value: T extends Payload ? ActionDirectInjection : ActionTransformedInjection) => ({ + provide: token, + useValue: value, + multi: true, + }); + + return { + token, + provider, + }; +}; diff --git a/libs/core/state/src/actions/injectable/map.factory.ts b/libs/core/state/src/actions/injectable/map.factory.ts new file mode 100644 index 0000000000..8c49055b7c --- /dev/null +++ b/libs/core/state/src/actions/injectable/map.factory.ts @@ -0,0 +1,62 @@ +import { + InjectionToken, + inject, +} from '@angular/core'; +import { Action } from '@ngrx/store'; + +import { createInjectableAction } from './factory'; +import { InjectableActionMap } from './map.type'; +import { ActionTransformedInjection } from './transformed.type'; + +/** + * The key in which the token for the injectable action map is stored. + */ +export const INJECTABLE_ACTION_MAP_KEY = 'εINJECTABLE_ACTION_MAP_KEY'; + +/** + * Creates a map of injectable actions and a token to hold them. + * + * @example + * ```ts + * export const { + * [INJECTABLE_ACTION_MAP_KEY]: MY_ACTION_MAP, + * myAction1: { + * token: MY_ACTION_1 + * provider: provideMyAction1 + * }, + * myAction2: { + * token: MY_ACTION_2 + * provider: provideMyAction2 + * } + * } = createInjectableActionMap<{myAction1: {field1: string}, myAction2: {field2: number}}>('MY_ACTION_MAP', ['MY_ACTION_1', 'MY_ACTION_2']) + * ``` + */ +export const createInjectableActionMap = >(tokenName: string, actions: Array) => { + const map = actions.reduce((acc, action) => { + acc[action] = createInjectableAction(String(action)); + return acc; + }, <{[K in keyof T]: ReturnType>}>{}); + return { + ...map, + [INJECTABLE_ACTION_MAP_KEY]: new InjectionToken>( + tokenName, + { + factory: () => { + const _map = >{}; + + for (const k in map) { + if (Object.hasOwn(map, k)) { + const tokenVal = inject(map[k].token); + _map[k] = tokenVal.reduce((acc, injection) => { + acc[injection.type] = 'transform' in injection ? injection : { type: injection.type, transform: (action: A) => action }; + return acc; + }, >>{}); + } + } + + return _map; + }, + }, + ), + }; +}; diff --git a/libs/core/state/src/actions/injectable/map.type.ts b/libs/core/state/src/actions/injectable/map.type.ts new file mode 100644 index 0000000000..6e339fd8c4 --- /dev/null +++ b/libs/core/state/src/actions/injectable/map.type.ts @@ -0,0 +1,8 @@ +import { Action } from '@ngrx/store'; + +import { ActionTransformedInjection } from './transformed.type'; + +/** + * A map of action names to {@link ActionTransformedInjection}s. + */ +export type InjectableActionMap, Actions extends Action = Action> = {[K in keyof T]: {[Type in Actions['type']]?: ActionTransformedInjection>}}; diff --git a/libs/core/state/src/actions/injectable/public_api.ts b/libs/core/state/src/actions/injectable/public_api.ts new file mode 100644 index 0000000000..cebc6f2fd3 --- /dev/null +++ b/libs/core/state/src/actions/injectable/public_api.ts @@ -0,0 +1,6 @@ +export * from './direct.type'; +export * from './factory'; +export * from './map.factory'; +export * from './transformed.type'; +export * from './type'; +export * from './map.type'; diff --git a/libs/core/state/src/actions/injectable/transformed.type.ts b/libs/core/state/src/actions/injectable/transformed.type.ts new file mode 100644 index 0000000000..f28cad3ca0 --- /dev/null +++ b/libs/core/state/src/actions/injectable/transformed.type.ts @@ -0,0 +1,16 @@ +import { Action } from '@ngrx/store'; + +/** + * An injection of an action that does not match the desired payload and therefore requires transformation. + */ +export interface ActionTransformedInjection { + /** + * The action type. + */ + type: Act['type']; + + /** + * A function that gets the payload from the action. + */ + transform: (action: Act) => Payload; +} diff --git a/libs/core/state/src/actions/injectable/type.ts b/libs/core/state/src/actions/injectable/type.ts new file mode 100644 index 0000000000..55e0fd50f5 --- /dev/null +++ b/libs/core/state/src/actions/injectable/type.ts @@ -0,0 +1,8 @@ +import { ActionDirectInjection } from './direct.type'; +import { ActionTransformedInjection } from './transformed.type'; + +/** + * An injectable action registration. + * Allows downstream libraries and apps define custom actions that will be automatically integrated into the declaring library's reducers and effects. + */ +export type ActionInjection = ActionDirectInjection | ActionTransformedInjection; diff --git a/libs/core/state/src/actions/public_api.ts b/libs/core/state/src/actions/public_api.ts index 479e4bca34..ee117e6e99 100644 --- a/libs/core/state/src/actions/public_api.ts +++ b/libs/core/state/src/actions/public_api.ts @@ -1 +1,2 @@ export * from './failure.type'; +export * from './injectable/public_api'; diff --git a/libs/core/state/src/reducers/injectable.factory.ts b/libs/core/state/src/reducers/injectable.factory.ts new file mode 100644 index 0000000000..d5f1e0492e --- /dev/null +++ b/libs/core/state/src/reducers/injectable.factory.ts @@ -0,0 +1,29 @@ +import { + Action, + ActionReducer, +} from '@ngrx/store'; + +import { InjectableActionMap } from '../actions/public_api'; + +/** + * Creates a reducer factory that will create reducers based on a passed {@link InjectableActionMap}. + * + * @param initialState The initial state of the reducer. + * @param handlers The business logic for each type of action that should be handled by this reducer. + */ +export const createInjectableReducerFactory = >( + initialState: TState, + handlers: {[K in keyof TMap]?: (state: TState, payload: TMap[K]) => TState}, +) => + (actions: InjectableActionMap): ActionReducer => { + const lookup = Object.keys(actions).reduce((acc, key) => { + Object.keys(actions[key]).forEach((actType) => { + acc[actType] = key; + }); + return acc; + }, {}); + return (state = initialState, action: Actions): TState => { + const handler = lookup[action.type]; + return handler ? handlers[handler](state, actions[handler][action.type].transform(action)) : state; + }; + }; diff --git a/libs/core/state/src/reducers/public_api.ts b/libs/core/state/src/reducers/public_api.ts index b920860318..d1fa7b9b9a 100644 --- a/libs/core/state/src/reducers/public_api.ts +++ b/libs/core/state/src/reducers/public_api.ts @@ -1,2 +1,3 @@ export { daffComposeReducers } from './create-meta'; export { daffIdentityReducer } from './identity'; +export { createInjectableReducerFactory } from './injectable.factory'; diff --git a/libs/docs/state/ng-package.json b/libs/docs/state/ng-package.json new file mode 100644 index 0000000000..0f621f8520 --- /dev/null +++ b/libs/docs/state/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json", + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/docs/state/src/actions/actions.ts b/libs/docs/state/src/actions/actions.ts new file mode 100644 index 0000000000..3ef941e837 --- /dev/null +++ b/libs/docs/state/src/actions/actions.ts @@ -0,0 +1,27 @@ +import { DaffFailable } from '@daffodil/core/state'; +import { DaffDocsItem } from '@daffodil/docs-utils'; + +/** + * Triggers the loading of the specified docs. + * + * @param docsId The docs ID. + */ +export interface DaffDocsLoadAction< + T extends DaffDocsItem = DaffDocsItem, +> { + docsId: T['id']; +} + +export interface DaffDocsLoadSuccessAction { + payload: Array; +} + +export const DAFF_DOCS_LOAD = 'DAFF_DOCS_LOAD_ACTIONS'; +export const DAFF_DOCS_LOAD_SUCCESS = 'DAFF_DOCS_LOAD_SUCCESS_ACTIONS'; +export const DAFF_DOCS_LOAD_FAILURE = 'DAFF_DOCS_LOAD_FAILURE_ACTIONS'; + +export interface DaffDocsActions { + [DAFF_DOCS_LOAD]: DaffDocsLoadAction; + [DAFF_DOCS_LOAD_SUCCESS]: DaffDocsLoadSuccessAction; + [DAFF_DOCS_LOAD_FAILURE]: DaffFailable; +} diff --git a/libs/docs/state/src/actions/public_api.ts b/libs/docs/state/src/actions/public_api.ts new file mode 100644 index 0000000000..f1ef3c1dbe --- /dev/null +++ b/libs/docs/state/src/actions/public_api.ts @@ -0,0 +1,2 @@ +export * from './token'; +export * from './actions'; diff --git a/libs/docs/state/src/actions/token.ts b/libs/docs/state/src/actions/token.ts new file mode 100644 index 0000000000..146dcf1989 --- /dev/null +++ b/libs/docs/state/src/actions/token.ts @@ -0,0 +1,27 @@ +import { + INJECTABLE_ACTION_MAP_KEY, + createInjectableActionMap, +} from '@daffodil/core/state'; + +import { + DAFF_DOCS_LOAD, + DAFF_DOCS_LOAD_FAILURE, + DAFF_DOCS_LOAD_SUCCESS, + DaffDocsActions, +} from './actions'; + +export const { + [INJECTABLE_ACTION_MAP_KEY]: DAFF_DOCS_ACTIONS, + [DAFF_DOCS_LOAD]: { + token: DAFF_DOCS_LOAD_ACTIONS, + provider: provideDaffDocsLoadActions, + }, + [DAFF_DOCS_LOAD_SUCCESS]: { + token: DAFF_DOCS_LOAD_SUCCESS_ACTIONS, + provider: provideDaffDocsLoadSuccessActions, + }, + [DAFF_DOCS_LOAD_FAILURE]: { + token: DAFF_DOCS_LOAD_FAILURE_ACTIONS, + provider: provideDaffDocsLoadFailureActions, + }, +} = createInjectableActionMap('DAFF_DOCS_ACTIONS', [DAFF_DOCS_LOAD, DAFF_DOCS_LOAD_SUCCESS, DAFF_DOCS_LOAD_FAILURE]); diff --git a/libs/docs/state/src/facades/docs/facade.interface.ts b/libs/docs/state/src/facades/docs/facade.interface.ts new file mode 100644 index 0000000000..0c0eea283f --- /dev/null +++ b/libs/docs/state/src/facades/docs/facade.interface.ts @@ -0,0 +1,27 @@ +import { Dictionary } from '@ngrx/entity'; +import { Observable } from 'rxjs'; + +import { + DaffStateError, + DaffOperationStateFacadeInterface, + DaffState, +} from '@daffodil/core/state'; +import { DaffDocsItem } from '@daffodil/docs-utils'; + +import { DaffDocsReducerState } from '../../reducers/public_api'; + +export interface DaffDocsFacadeInterface extends DaffOperationStateFacadeInterface { + loading$: Observable; + errors$: Observable; + loadingState$: Observable; + resolving$: Observable; + mutating$: Observable; + hasErrors$: Observable; + + docsItems$: Observable>; + docsEntities$: Observable>; + docsIds$: Observable; + docsCount$: Observable; + + getDocs$(docsId: T['id']): Observable; +} diff --git a/libs/docs/state/src/facades/docs/facade.spec.ts b/libs/docs/state/src/facades/docs/facade.spec.ts new file mode 100644 index 0000000000..7f83676888 --- /dev/null +++ b/libs/docs/state/src/facades/docs/facade.spec.ts @@ -0,0 +1,206 @@ +import { TestBed } from '@angular/core/testing'; +import { + Action, + combineReducers, + Store, + StoreModule, +} from '@ngrx/store'; +import { cold } from 'jasmine-marbles'; +import { identity } from 'rxjs'; + +import { + DaffFailureAction, + DaffStateError, + InjectableActionMap, +} from '@daffodil/core/state'; +import { + DaffDocsStateRootSlice, + DAFF_DOCS_STORE_FEATURE_KEY, + DaffDocsLoadAction, + DaffDocsLoadSuccessAction, + DAFF_DOCS_LOAD, + DAFF_DOCS_LOAD_FAILURE, + DAFF_DOCS_LOAD_SUCCESS, + DaffDocsActions, + daffDocsReducerFactory, + daffDocsEntitiesReducerFactory, +} from '@daffodil/docs/state'; +import { DaffDocsItemFactory } from '@daffodil/docs/testing'; +import { DaffDocsItem } from '@daffodil/docs-utils'; + +import { DaffDocsFacade } from './facade'; + +class MockLoadAction implements DaffDocsLoadAction, Action { + readonly type = 'mockLoad'; + constructor(public docsId: string) {} +} + +class MockLoadSuccessAction implements DaffDocsLoadSuccessAction, Action { + readonly type = 'mockLoadSuccess'; + constructor(public payload: Array) {} +} + +class MockLoadFailureAction implements DaffFailureAction, Action { + readonly type = 'mockLoadFailure'; + constructor(public payload: Array) {} +} + +type Actions = MockLoadAction | MockLoadSuccessAction | MockLoadFailureAction; + +const actionMap: InjectableActionMap = { + [DAFF_DOCS_LOAD]: { + mockLoad: { type: 'mockLoad', transform: identity }, + }, + [DAFF_DOCS_LOAD_SUCCESS]: { + mockLoadSuccess: { type: 'mockLoadSuccess', transform: identity }, + }, + [DAFF_DOCS_LOAD_FAILURE]: { + mockLoadFailure: { type: 'mockLoadFailure', transform: identity }, + }, +}; + +describe('@daffodil/docs | DaffDocsFacade', () => { + let store: Store; + let facade: DaffDocsFacade; + let docsFactory: DaffDocsItemFactory; + + let mockDocsItem: DaffDocsItem; + let docsId: DaffDocsItem['id']; + let errors: string[]; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ + [DAFF_DOCS_STORE_FEATURE_KEY]: combineReducers({ + docs: daffDocsReducerFactory(actionMap), + docsEntities: daffDocsEntitiesReducerFactory(actionMap), + }), + }), + ], + providers: [ + DaffDocsFacade, + ], + }); + + store = TestBed.inject(Store); + facade = TestBed.inject(DaffDocsFacade); + docsFactory = TestBed.inject(DaffDocsItemFactory); + + mockDocsItem = docsFactory.create(); + docsId = mockDocsItem.id; + errors = []; + }); + + it('should be created', () => { + expect(facade).toBeTruthy(); + }); + + it('should be able to dispatch an action to the store', () => { + spyOn(store, 'dispatch'); + const action = { type: 'SOME_TYPE' }; + + facade.dispatch(action); + expect(store.dispatch).toHaveBeenCalledWith(action); + expect(store.dispatch).toHaveBeenCalledTimes(1); + }); + + describe('loading$', () => { + it('should be false if the docs is not loading', () => { + const expected = cold('a', { a: false }); + expect(facade.loading$).toBeObservable(expected); + }); + + it('should be true if the docs is loading', () => { + const expected = cold('a', { a: true }); + store.dispatch(new MockLoadAction(docsId)); + expect(facade.loading$).toBeObservable(expected); + }); + }); + + describe('errors$', () => { + it('should initially be an empty array', () => { + const expected = cold('a', { a: errors }); + expect(facade.errors$).toBeObservable(expected); + }); + + it('should contain an error upon a failed load', () => { + const error: DaffStateError = { code: 'code', recoverable: false, message: 'message' }; + const expected = cold('a', { a: [error]}); + store.dispatch(new MockLoadFailureAction([error])); + expect(facade.errors$).toBeObservable(expected); + }); + }); + + describe('docsItems$', () => { + it('should initially be an empty array', () => { + const expected = cold('a', { a: []}); + expect(facade.docsItems$).toBeObservable(expected); + }); + + it('should be the docsItems upon a successful load', () => { + const expected = cold('a', { a: [mockDocsItem]}); + store.dispatch(new MockLoadSuccessAction([mockDocsItem])); + expect(facade.docsItems$).toBeObservable(expected); + }); + }); + + describe('docsEntities$', () => { + it('should initially be an empty dict', () => { + const expected = cold('a', { a: {}}); + expect(facade.docsEntities$).toBeObservable(expected); + }); + + it('should be the docsEntities upon a successful load', () => { + const expected = cold('a', { a: { [docsId]: mockDocsItem }}); + store.dispatch(new MockLoadSuccessAction([mockDocsItem])); + expect(facade.docsEntities$).toBeObservable(expected); + }); + }); + + describe('docsIds$', () => { + it('should initially be an empty array', () => { + const expected = cold('a', { a: []}); + expect(facade.docsIds$).toBeObservable(expected); + }); + + it('should contain the docs id upon a successful docs load', () => { + const expected = cold('a', { a: [docsId]}); + store.dispatch(new MockLoadSuccessAction([mockDocsItem])); + expect(facade.docsIds$).toBeObservable(expected); + }); + }); + + describe('docsCount$', () => { + it('should initially be zero', () => { + const expected = cold('a', { a: 0 }); + expect(facade.docsCount$).toBeObservable(expected); + }); + + it('should be one upon a successful docs load', () => { + const expected = cold('a', { a: 1 }); + store.dispatch(new MockLoadSuccessAction([mockDocsItem])); + expect(facade.docsCount$).toBeObservable(expected); + }); + }); + + describe('getDocs$', () => { + it('should initially be null', () => { + const expected = cold('a', { a: null }); + + expect(facade.getDocs$(docsId)).toBeObservable(expected); + }); + + describe('when an docs has been loaded', () => { + beforeEach(() => { + store.dispatch(new MockLoadSuccessAction([mockDocsItem])); + }); + + it('should select the docs', () => { + const expected = cold('a', { a: mockDocsItem }); + + expect(facade.getDocs$(docsId)).toBeObservable(expected); + }); + }); + }); +}); diff --git a/libs/docs/state/src/facades/docs/facade.ts b/libs/docs/state/src/facades/docs/facade.ts new file mode 100644 index 0000000000..715b223e88 --- /dev/null +++ b/libs/docs/state/src/facades/docs/facade.ts @@ -0,0 +1,80 @@ +import { Injectable } from '@angular/core'; +import { Dictionary } from '@ngrx/entity'; +import { + Action, + Store, + select, +} from '@ngrx/store'; +import { Observable } from 'rxjs'; + +import { + DaffState, + DaffStateError, +} from '@daffodil/core/state'; +import { DaffDocsItem } from '@daffodil/docs-utils'; + +import { DaffDocsFacadeInterface } from './facade.interface'; +import { DaffDocsStateRootSlice } from '../../reducers/public_api'; +import { DaffDocsEntitySelectors } from '../../selectors/entities.selector'; +import { getDaffDocsSelectors } from '../../selectors/public_api'; + +/** + * @inheritdoc + */ +@Injectable({ + providedIn: 'root', +}) +export class DaffDocsFacade implements DaffDocsFacadeInterface { + loading$: Observable; + errors$: Observable; + loadingState$: Observable; + resolving$: Observable; + mutating$: Observable; + hasErrors$: Observable; + + docsItems$: Observable>; + docsEntities$: Observable>; + docsIds$: Observable; + docsCount$: Observable; + + _docs: DaffDocsEntitySelectors['selectDocs']; + + constructor(private store: Store>) { + const { + selectDocsIds, + selectDocsEntities, + selectDocsTotal, + selectLoading, + selectErrors, + selectHasErrors, + selectMutating, + selectResolving, + selectLoadingState, + selectAllDocsEntities, + + selectDocs, + } = getDaffDocsSelectors(); + + this.loading$ = this.store.pipe(select(selectLoading)); + this.errors$ = this.store.pipe(select(selectErrors)); + this.loadingState$ = this.store.pipe(select(selectLoadingState)); + this.resolving$ = this.store.pipe(select(selectResolving)); + this.mutating$ = this.store.pipe(select(selectMutating)); + this.hasErrors$ = this.store.pipe(select(selectHasErrors)); + + this.docsEntities$ = this.store.pipe(select(selectDocsEntities)); + this.docsItems$ = this.store.pipe(select(selectAllDocsEntities)); + this.docsIds$ = this.store.pipe(select(selectDocsIds)); + this.docsCount$ = this.store.pipe(select(selectDocsTotal)); + + this._docs = selectDocs; + } + + getDocs$(docsId: T['id']): Observable { + return this.store.pipe(select(this._docs(docsId))); + } + + dispatch(action: Action) { + this.store.dispatch(action); + } +} diff --git a/libs/docs/state/src/facades/public_api.ts b/libs/docs/state/src/facades/public_api.ts new file mode 100644 index 0000000000..c3d716a0dd --- /dev/null +++ b/libs/docs/state/src/facades/public_api.ts @@ -0,0 +1,2 @@ +export { DaffDocsFacadeInterface } from './docs/facade.interface'; +export { DaffDocsFacade } from './docs/facade'; diff --git a/libs/docs/state/src/index.ts b/libs/docs/state/src/index.ts new file mode 100644 index 0000000000..4aaf8f92ed --- /dev/null +++ b/libs/docs/state/src/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/libs/docs/state/src/injection-tokens/error-matcher.token.ts b/libs/docs/state/src/injection-tokens/error-matcher.token.ts new file mode 100644 index 0000000000..4da1fbffec --- /dev/null +++ b/libs/docs/state/src/injection-tokens/error-matcher.token.ts @@ -0,0 +1,17 @@ +import { createSingleInjectionToken } from '@daffodil/core'; +import { daffTransformErrorToStateError } from '@daffodil/core/state'; + +export const { + /** + * Transforms `DaffError`s into `DaffStateError`s before they are serialized into state. + * Can be used to further refine Daffodil errors into more specific app errors. + */ + token: DAFF_DOCS_ERROR_MATCHER, + /** + * Provider function for {@link DAFF_DOCS_ERROR_MATCHER}. + */ + provider: provideDaffDocsErrorMatcher, +} = createSingleInjectionToken( + 'DAFF_DOCS_ERROR_MATCHER', + { factory: () => daffTransformErrorToStateError }, +); diff --git a/libs/docs/state/src/injection-tokens/public_api.ts b/libs/docs/state/src/injection-tokens/public_api.ts new file mode 100644 index 0000000000..315411d8fd --- /dev/null +++ b/libs/docs/state/src/injection-tokens/public_api.ts @@ -0,0 +1 @@ +export * from './error-matcher.token'; diff --git a/libs/docs/state/src/public_api.ts b/libs/docs/state/src/public_api.ts new file mode 100644 index 0000000000..3922cd474e --- /dev/null +++ b/libs/docs/state/src/public_api.ts @@ -0,0 +1,7 @@ +export * from './actions/public_api'; +export * from './reducers/public_api'; +export * from './selectors/public_api'; +export * from './facades/public_api'; +export * from './injection-tokens/public_api'; + +export { DaffDocsStateModule } from './state.module'; diff --git a/libs/docs/state/src/reducers/docs/initial-state.ts b/libs/docs/state/src/reducers/docs/initial-state.ts new file mode 100644 index 0000000000..76e0e8bcff --- /dev/null +++ b/libs/docs/state/src/reducers/docs/initial-state.ts @@ -0,0 +1,5 @@ +import { daffOperationInitialState } from '@daffodil/core/state'; + +import { DaffDocsReducerState } from './reducer.interface'; + +export const daffDocsInitialState: DaffDocsReducerState = daffOperationInitialState; diff --git a/libs/docs/state/src/reducers/docs/public_api.ts b/libs/docs/state/src/reducers/docs/public_api.ts new file mode 100644 index 0000000000..7b17b74447 --- /dev/null +++ b/libs/docs/state/src/reducers/docs/public_api.ts @@ -0,0 +1,3 @@ +export { daffDocsReducerFactory } from './reducer'; +export { DaffDocsReducerState } from './reducer.interface'; +export { daffDocsInitialState } from './initial-state'; diff --git a/libs/docs/state/src/reducers/docs/reducer.interface.ts b/libs/docs/state/src/reducers/docs/reducer.interface.ts new file mode 100644 index 0000000000..b211a0b6d6 --- /dev/null +++ b/libs/docs/state/src/reducers/docs/reducer.interface.ts @@ -0,0 +1,3 @@ +import { DaffOperationState } from '@daffodil/core/state'; + +export type DaffDocsReducerState = DaffOperationState; diff --git a/libs/docs/state/src/reducers/docs/reducer.spec.ts b/libs/docs/state/src/reducers/docs/reducer.spec.ts new file mode 100644 index 0000000000..c875eda3fd --- /dev/null +++ b/libs/docs/state/src/reducers/docs/reducer.spec.ts @@ -0,0 +1,151 @@ +import { TestBed } from '@angular/core/testing'; +import { + Action, + ActionReducer, +} from '@ngrx/store'; + +import { identity } from '@daffodil/core'; +import { + DaffFailureAction, + DaffState, + DaffStateError, + InjectableActionMap, +} from '@daffodil/core/state'; +import { + daffDocsInitialState as initialState, + DaffDocsReducerState, + DAFF_DOCS_LOAD, + DAFF_DOCS_LOAD_FAILURE, + DAFF_DOCS_LOAD_SUCCESS, + DaffDocsActions, + DaffDocsLoadAction, + DaffDocsLoadSuccessAction, +} from '@daffodil/docs/state'; +import { DaffDocsItemFactory } from '@daffodil/docs/testing'; +import { DaffDocsItem } from '@daffodil/docs-utils'; + +import { daffDocsReducerFactory as reducerFactory } from './reducer'; + +class MockLoadAction implements DaffDocsLoadAction, Action { + readonly type = 'mockLoad'; + constructor(public docsId: string) {} +} + +class MockLoadSuccessAction implements DaffDocsLoadSuccessAction, Action { + readonly type = 'mockLoadSuccess'; + constructor(public payload: Array) {} +} + +class MockLoadFailureAction implements DaffFailureAction, Action { + readonly type = 'mockLoadFailure'; + constructor(public payload: Array) {} +} + +type Actions = MockLoadAction | MockLoadSuccessAction | MockLoadFailureAction; + +const actionMap: InjectableActionMap = { + [DAFF_DOCS_LOAD]: { + mockLoad: { type: 'mockLoad', transform: identity }, + }, + [DAFF_DOCS_LOAD_SUCCESS]: { + mockLoadSuccess: { type: 'mockLoadSuccess', transform: identity }, + }, + [DAFF_DOCS_LOAD_FAILURE]: { + mockLoadFailure: { type: 'mockLoadFailure', transform: identity }, + }, +}; + +describe('@daffodil/docs/state | daffDocsReducer', () => { + let docsItemFactory: DaffDocsItemFactory; + let mockDocsItem: DaffDocsItem; + let reducer: ActionReducer; + + beforeEach(() => { + docsItemFactory = TestBed.inject(DaffDocsItemFactory); + + reducer = reducerFactory(actionMap); + mockDocsItem = docsItemFactory.create(); + }); + + describe('when an unknown action is triggered', () => { + it('should return the current state', () => { + const action = {}; + + const result = reducer(initialState, action); + + expect(result).toBe(initialState); + }); + }); + + describe('when DocsLoadAction is triggered', () => { + it('sets loading state to true', () => { + const docsLoadAction = new MockLoadAction('MockcsId'); + + const result = reducer(initialState, docsLoadAction); + + expect(result.daffState).toEqual(DaffState.Resolving); + }); + }); + + describe('when DocsLoadSuccessAction is triggered', () => { + let mockError: DaffStateError; + let result: DaffDocsReducerState; + let state: DaffDocsReducerState; + + beforeEach(() => { + mockError = { + code: 'error code', + message: 'error message', + }; + state = { + ...initialState, + daffState: DaffState.Resolving, + daffErrors: [mockError], + }; + + const docsLoadSuccess = new MockLoadSuccessAction([mockDocsItem]); + + result = reducer(state, docsLoadSuccess); + }); + + it('sets loading to false', () => { + expect(result.daffState).toEqual(DaffState.Stable); + }); + + it('should reset errors', () => { + expect(result.daffErrors).toEqual([]); + }); + }); + + describe('when DocsLoadFailureAction is triggered', () => { + let result: DaffDocsReducerState; + let state: DaffDocsReducerState; + let mockError: DaffStateError; + + beforeEach(() => { + mockError = { + code: 'error code', + message: 'error message', + }; + state = { + ...initialState, + daffState: DaffState.Resolving, + daffErrors: [ + { code: 'firstErrorCode', message: 'firstErrorMessage' }, + ], + }; + + const docsLoadFailureAction = new MockLoadFailureAction([mockError]); + + result = reducer(state, docsLoadFailureAction); + }); + + it('adds the error in action.payload to state.daffErrors', () => { + expect(result.daffErrors).toEqual([mockError]); + }); + + it('sets loading to error', () => { + expect(result.daffState).toEqual(DaffState.Error); + }); + }); +}); diff --git a/libs/docs/state/src/reducers/docs/reducer.ts b/libs/docs/state/src/reducers/docs/reducer.ts new file mode 100644 index 0000000000..ed67d8d47e --- /dev/null +++ b/libs/docs/state/src/reducers/docs/reducer.ts @@ -0,0 +1,24 @@ +import { + createInjectableReducerFactory, + daffCompleteOperation, + daffOperationFailed, + daffStartResolution, +} from '@daffodil/core/state'; + +import { daffDocsInitialState } from './initial-state'; +import { DaffDocsReducerState } from './reducer.interface'; +import { + DAFF_DOCS_LOAD, + DAFF_DOCS_LOAD_FAILURE, + DAFF_DOCS_LOAD_SUCCESS, + DaffDocsActions, +} from '../../actions/public_api'; + +export const daffDocsReducerFactory = createInjectableReducerFactory( + daffDocsInitialState, + { + [DAFF_DOCS_LOAD]: daffStartResolution, + [DAFF_DOCS_LOAD_SUCCESS]: daffCompleteOperation, + [DAFF_DOCS_LOAD_FAILURE]: (state, { payload }) => daffOperationFailed(payload, state), + }, +); diff --git a/libs/docs/state/src/reducers/entities/entities-adapter.ts b/libs/docs/state/src/reducers/entities/entities-adapter.ts new file mode 100644 index 0000000000..e07c508dfe --- /dev/null +++ b/libs/docs/state/src/reducers/entities/entities-adapter.ts @@ -0,0 +1,15 @@ +import { + EntityAdapter, + createEntityAdapter, +} from '@ngrx/entity'; + +import { DaffDocsItem } from '@daffodil/docs-utils'; + +/** + * Docs Adapter for changing/overwriting entity state. + */ +export const daffGetDocsAdapter = (() => { + let cache: any; + return (): EntityAdapter => + cache = cache || createEntityAdapter(); +})(); diff --git a/libs/docs/state/src/reducers/entities/entities-initial-state.ts b/libs/docs/state/src/reducers/entities/entities-initial-state.ts new file mode 100644 index 0000000000..0f9cafba5d --- /dev/null +++ b/libs/docs/state/src/reducers/entities/entities-initial-state.ts @@ -0,0 +1,7 @@ +import { daffGetDocsAdapter } from './entities-adapter'; +import { DaffDocsEntityState } from './entities-state.interface'; + +/** + * Initial state for docs entity state. + */ +export const daffDocsEntitiesInitialState: DaffDocsEntityState = daffGetDocsAdapter().getInitialState(); diff --git a/libs/docs/state/src/reducers/entities/entities-state.interface.ts b/libs/docs/state/src/reducers/entities/entities-state.interface.ts new file mode 100644 index 0000000000..1aacdfb782 --- /dev/null +++ b/libs/docs/state/src/reducers/entities/entities-state.interface.ts @@ -0,0 +1,8 @@ +import { EntityState } from '@ngrx/entity'; + +import { DaffDocsItem } from '@daffodil/docs-utils'; + +/** + * Interface for docs entity state. + */ +export type DaffDocsEntityState = EntityState; diff --git a/libs/docs/state/src/reducers/entities/entities.reducer.spec.ts b/libs/docs/state/src/reducers/entities/entities.reducer.spec.ts new file mode 100644 index 0000000000..e33d23e30f --- /dev/null +++ b/libs/docs/state/src/reducers/entities/entities.reducer.spec.ts @@ -0,0 +1,93 @@ +import { TestBed } from '@angular/core/testing'; +import { + Action, + ActionReducer, +} from '@ngrx/store'; + +import { identity } from '@daffodil/core'; +import { + DaffFailureAction, + DaffStateError, + InjectableActionMap, +} from '@daffodil/core/state'; +import { + daffDocsEntitiesInitialState as initialState, + DaffDocsLoadAction, + DaffDocsLoadSuccessAction, + DaffDocsEntityState, + DAFF_DOCS_LOAD, + DAFF_DOCS_LOAD_FAILURE, + DAFF_DOCS_LOAD_SUCCESS, + DaffDocsActions, +} from '@daffodil/docs/state'; +import { DaffDocsItemFactory } from '@daffodil/docs/testing'; +import { DaffDocsItem } from '@daffodil/docs-utils'; + +import { daffDocsEntitiesReducerFactory as reducerFactory } from './entities.reducer'; + +class MockLoadAction implements DaffDocsLoadAction, Action { + readonly type = 'mockLoad'; + constructor(public docsId: string) {} +} + +class MockLoadSuccessAction implements DaffDocsLoadSuccessAction, Action { + readonly type = 'mockLoadSuccess'; + constructor(public payload: Array) {} +} + +class MockLoadFailureAction implements DaffFailureAction, Action { + readonly type = 'mockLoadFailure'; + constructor(public payload: Array) {} +} + +type Actions = MockLoadAction | MockLoadSuccessAction | MockLoadFailureAction; + +const actionMap: InjectableActionMap = { + [DAFF_DOCS_LOAD]: { + mockLoad: { type: 'mockLoad', transform: identity }, + }, + [DAFF_DOCS_LOAD_SUCCESS]: { + mockLoadSuccess: { type: 'mockLoadSuccess', transform: identity }, + }, + [DAFF_DOCS_LOAD_FAILURE]: { + mockLoadFailure: { type: 'mockLoadFailure', transform: identity }, + }, +}; + +describe('@daffodil/docs/state | daffDocsEntitiesReducerFactory', () => { + let docsItemFactory: DaffDocsItemFactory; + let mockDocsItem: DaffDocsItem; + let docsId: DaffDocsItem['id']; + let reducer: ActionReducer; + let result: DaffDocsEntityState; + + beforeEach(() => { + reducer = reducerFactory(actionMap); + docsItemFactory = TestBed.inject(DaffDocsItemFactory); + + mockDocsItem = docsItemFactory.create(); + docsId = mockDocsItem.id; + }); + + describe('when an unknown action is triggered', () => { + it('should return the current state', () => { + const action = {}; + + result = reducer(initialState, action); + + expect(result).toBe(initialState); + }); + }); + + describe('when the success action is triggered', () => { + beforeEach(() => { + const docsLoadSuccess = new MockLoadSuccessAction([mockDocsItem]); + + result = reducer(initialState, docsLoadSuccess); + }); + + it('should set docs from action.payload', () => { + expect(result.entities[docsId]).toEqual(mockDocsItem); + }); + }); +}); diff --git a/libs/docs/state/src/reducers/entities/entities.reducer.ts b/libs/docs/state/src/reducers/entities/entities.reducer.ts new file mode 100644 index 0000000000..66e7b73840 --- /dev/null +++ b/libs/docs/state/src/reducers/entities/entities.reducer.ts @@ -0,0 +1,19 @@ +import { createInjectableReducerFactory } from '@daffodil/core/state'; + +import { daffGetDocsAdapter } from './entities-adapter'; +import { daffDocsEntitiesInitialState } from './entities-initial-state'; +import { DaffDocsEntityState } from './entities-state.interface'; +import { + DAFF_DOCS_LOAD_SUCCESS, + DaffDocsActions, +} from '../../actions/actions'; + +/** + * Reducer function that catches actions and changes/overwrites docs entities state. + */ +export const daffDocsEntitiesReducerFactory = createInjectableReducerFactory( + daffDocsEntitiesInitialState, + { + [DAFF_DOCS_LOAD_SUCCESS]: (state, { payload }) => daffGetDocsAdapter().upsertMany(payload, state), + }, +); diff --git a/libs/docs/state/src/reducers/entities/public_api.ts b/libs/docs/state/src/reducers/entities/public_api.ts new file mode 100644 index 0000000000..f8ee91fa75 --- /dev/null +++ b/libs/docs/state/src/reducers/entities/public_api.ts @@ -0,0 +1,4 @@ +export { daffGetDocsAdapter } from './entities-adapter'; +export { daffDocsEntitiesInitialState } from './entities-initial-state'; +export { DaffDocsEntityState } from './entities-state.interface'; +export { daffDocsEntitiesReducerFactory } from './entities.reducer'; diff --git a/libs/docs/state/src/reducers/public_api.ts b/libs/docs/state/src/reducers/public_api.ts new file mode 100644 index 0000000000..70e60126da --- /dev/null +++ b/libs/docs/state/src/reducers/public_api.ts @@ -0,0 +1,9 @@ +export { + DaffDocsReducersState, + DaffDocsStateRootSlice, +} from './reducers.interface'; +export { DAFF_DOCS_STORE_FEATURE_KEY } from './store-feature-key'; + +export * from './entities/public_api'; +export * from './docs/public_api'; +export * from './token/public_api'; diff --git a/libs/docs/state/src/reducers/reducers.interface.ts b/libs/docs/state/src/reducers/reducers.interface.ts new file mode 100644 index 0000000000..1b5f5ddba7 --- /dev/null +++ b/libs/docs/state/src/reducers/reducers.interface.ts @@ -0,0 +1,14 @@ +import { DaffDocsItem } from '@daffodil/docs-utils'; + +import { DaffDocsReducerState } from './docs/reducer.interface'; +import { DaffDocsEntityState } from './entities/public_api'; +import { DAFF_DOCS_STORE_FEATURE_KEY } from './store-feature-key'; + +export interface DaffDocsReducersState { + docs: DaffDocsReducerState; + docsEntities: DaffDocsEntityState; +} + +export interface DaffDocsStateRootSlice { + [DAFF_DOCS_STORE_FEATURE_KEY]: DaffDocsReducersState; +} diff --git a/libs/docs/state/src/reducers/store-feature-key.ts b/libs/docs/state/src/reducers/store-feature-key.ts new file mode 100644 index 0000000000..c319d91432 --- /dev/null +++ b/libs/docs/state/src/reducers/store-feature-key.ts @@ -0,0 +1 @@ +export const DAFF_DOCS_STORE_FEATURE_KEY = 'daffDocs'; diff --git a/libs/docs/state/src/reducers/token/extra.token.spec.ts b/libs/docs/state/src/reducers/token/extra.token.spec.ts new file mode 100644 index 0000000000..c39ee0bd35 --- /dev/null +++ b/libs/docs/state/src/reducers/token/extra.token.spec.ts @@ -0,0 +1,49 @@ +import { TestBed } from '@angular/core/testing'; +import { ActionReducer } from '@ngrx/store'; + +import { + daffDocsInitialState, + DaffDocsReducersState, + daffGetDocsAdapter, +} from '@daffodil/docs/state'; + +import { + provideDaffDocsExtraReducers, + DAFF_DOCS_EXTRA_REDUCERS, +} from './extra.token'; + +describe('@daffodil/docs/state | provideDaffDocsExtraReducers', () => { + let reducers: Array>; + let result: Array>; + + beforeEach(() => { + const initialState: DaffDocsReducersState = { + docs: { + ...daffDocsInitialState, + daffErrors: [{ + code: 'code', + message: 'already in state', + }], + }, + docsEntities: daffGetDocsAdapter().getInitialState(), + }; + reducers = [ + (state = initialState, action) => state, + (state = initialState, action) => state, + ]; + + TestBed.configureTestingModule({ + providers: [ + ...provideDaffDocsExtraReducers(...reducers), + ], + }); + + result = TestBed.inject( DAFF_DOCS_EXTRA_REDUCERS); + }); + + it('should provide the reducers to the token', () => { + reducers.forEach(reducer => { + expect(result).toContain(reducer); + }); + }); +}); diff --git a/libs/docs/state/src/reducers/token/extra.token.ts b/libs/docs/state/src/reducers/token/extra.token.ts new file mode 100644 index 0000000000..810b04b032 --- /dev/null +++ b/libs/docs/state/src/reducers/token/extra.token.ts @@ -0,0 +1,32 @@ +import { ActionReducer } from '@ngrx/store'; + +import { createMultiInjectionToken } from '@daffodil/core'; + +import { DaffDocsReducersState } from '../reducers.interface'; + +export const { + /** + * A token to hold the injectable extra reducers. + * + * Prefer using {@link provideDaffDocsExtraReducers}. + */ + token: DAFF_DOCS_EXTRA_REDUCERS, + + /** + * Provides additional reducers that run after the standard Daffodil docs reducers. + * + * @example + * ```ts + * providers: [ + * ...provideDaffDocsExtraReducers( + * myReducer1, + * myReducer2 + * ) + * ] + * ``` + */ + provider: provideDaffDocsExtraReducers, +} = createMultiInjectionToken>( + 'DAFF_DOCS_EXTRA_REDUCERS', + { providedIn: 'any' }, +); diff --git a/libs/docs/state/src/reducers/token/public_api.ts b/libs/docs/state/src/reducers/token/public_api.ts new file mode 100644 index 0000000000..02076c50dd --- /dev/null +++ b/libs/docs/state/src/reducers/token/public_api.ts @@ -0,0 +1,4 @@ +export { + provideDaffDocsExtraReducers, + DAFF_DOCS_EXTRA_REDUCERS, +} from './extra.token'; diff --git a/libs/docs/state/src/reducers/token/reducers.token.spec.ts b/libs/docs/state/src/reducers/token/reducers.token.spec.ts new file mode 100644 index 0000000000..d4242be9eb --- /dev/null +++ b/libs/docs/state/src/reducers/token/reducers.token.spec.ts @@ -0,0 +1,61 @@ +import { TestBed } from '@angular/core/testing'; +import { ActionReducer } from '@ngrx/store'; + +import { DaffStateError } from '@daffodil/core/state'; +import { + provideDaffDocsExtraReducers, + DaffDocsReducersState, + daffDocsInitialState, + daffGetDocsAdapter, +} from '@daffodil/docs/state'; + +import { DAFF_DOCS_REDUCERS } from './reducers.token'; + +describe('@daffodil/docs/state | provideDaffDocsExtraReducers', () => { + let extraError: DaffStateError; + + let extraReducer: ActionReducer; + let reducer: ActionReducer; + let result: DaffDocsReducersState; + + beforeEach(() => { + const initialState: DaffDocsReducersState = { + docs: { + ...daffDocsInitialState, + daffErrors: [{ + code: 'code', + message: 'already in state', + }], + }, + docsEntities: daffGetDocsAdapter().getInitialState(), + }; + extraError = { + code: 'code', + message: 'an injected error', + }; + extraReducer = (state = initialState, action) => ({ + ...state, + docs: { + ...state.docs, + daffErrors: [ + ...state.docs.daffErrors, + extraError, + ], + }, + }); + + TestBed.configureTestingModule({ + providers: [ + ...provideDaffDocsExtraReducers(extraReducer), + ], + }); + + reducer = TestBed.inject( DAFF_DOCS_REDUCERS); + + result = reducer(initialState, { type: 'action' }); + }); + + it('should run the extra reducer after the daffodil reducers', () => { + expect(result.docs.daffErrors[1]).toEqual(extraError); + }); +}); diff --git a/libs/docs/state/src/reducers/token/reducers.token.ts b/libs/docs/state/src/reducers/token/reducers.token.ts new file mode 100644 index 0000000000..3116cd1494 --- /dev/null +++ b/libs/docs/state/src/reducers/token/reducers.token.ts @@ -0,0 +1,42 @@ +import { inject } from '@angular/core'; +import { + ActionReducer, + combineReducers, +} from '@ngrx/store'; + +import { createSingleInjectionToken } from '@daffodil/core'; +import { daffComposeReducers } from '@daffodil/core/state'; + +import { DAFF_DOCS_EXTRA_REDUCERS } from './extra.token'; +import { DAFF_DOCS_ACTIONS } from '../../actions/public_api'; +import { daffDocsReducerFactory } from '../docs/reducer'; +import { daffDocsEntitiesReducerFactory } from '../entities/public_api'; +import { DaffDocsReducersState } from '../reducers.interface'; + +export const { + /** + * An internal token to hold the Daffodil docs reducers. + * Includes the extra and standard reducers. + * + * @docs-private + */ + token: DAFF_DOCS_REDUCERS, + /** + * Provider function for {@link DAFF_DOCS_REDUCERS}. + * + * @docs-private + */ + provider: provideDaffDocsReducers, +} = createSingleInjectionToken>( + ' DAFF_DOCS_REDUCERS', + { + providedIn: 'any', + factory: () => daffComposeReducers([ + combineReducers({ + docs: daffDocsReducerFactory(inject(DAFF_DOCS_ACTIONS)), + docsEntities: daffDocsEntitiesReducerFactory(inject(DAFF_DOCS_ACTIONS)), + }), + ...inject(DAFF_DOCS_EXTRA_REDUCERS), + ]), + }, +); diff --git a/libs/docs/state/src/selectors/all.selector.ts b/libs/docs/state/src/selectors/all.selector.ts new file mode 100644 index 0000000000..3cc050a133 --- /dev/null +++ b/libs/docs/state/src/selectors/all.selector.ts @@ -0,0 +1,29 @@ +import { DaffDocsItem } from '@daffodil/docs-utils'; + +import { + DaffDocsEntitySelectors, + getDaffDocsEntitySelectors, +} from './entities.selector'; +import { + DaffDocsFeatureSelector, + getDaffDocsReducersStateSelector, +} from './feature.selector'; +import { + DaffDocsSelectors, + getDocsSelectors, +} from './selector'; + +export interface DaffDocsAllSelectors extends + DaffDocsEntitySelectors, + DaffDocsSelectors, + DaffDocsFeatureSelector {} + +export const getDaffDocsSelectors = (() => { + let cache: any; + return (): DaffDocsAllSelectors => + cache = cache || { + ...getDocsSelectors(), + ...getDaffDocsEntitySelectors(), + ...getDaffDocsReducersStateSelector(), + }; +})(); diff --git a/libs/docs/state/src/selectors/entities.selector.spec.ts b/libs/docs/state/src/selectors/entities.selector.spec.ts new file mode 100644 index 0000000000..9a783cca9f --- /dev/null +++ b/libs/docs/state/src/selectors/entities.selector.spec.ts @@ -0,0 +1,184 @@ +import { TestBed } from '@angular/core/testing'; +import { + Store, + StoreModule, + select, + combineReducers, + Action, +} from '@ngrx/store'; +import { cold } from 'jasmine-marbles'; + +import { identity } from '@daffodil/core'; +import { + DaffFailureAction, + DaffStateError, + InjectableActionMap, +} from '@daffodil/core/state'; +import { + DAFF_DOCS_LOAD, + DAFF_DOCS_LOAD_FAILURE, + DAFF_DOCS_LOAD_SUCCESS, + DAFF_DOCS_STORE_FEATURE_KEY, + DaffDocsActions, + daffDocsEntitiesReducerFactory, + DaffDocsLoadAction, + DaffDocsLoadSuccessAction, + daffDocsReducerFactory, + DaffDocsStateRootSlice, +} from '@daffodil/docs/state'; +import { DaffDocsItemFactory } from '@daffodil/docs/testing'; +import { DaffDocsItem } from '@daffodil/docs-utils'; + +import { getDaffDocsEntitySelectors } from './entities.selector'; + +class MockLoadAction implements DaffDocsLoadAction, Action { + readonly type = 'mockLoad'; + constructor(public docsId: string) {} +} + +class MockLoadSuccessAction implements DaffDocsLoadSuccessAction, Action { + readonly type = 'mockLoadSuccess'; + constructor(public payload: Array) {} +} + +class MockLoadFailureAction implements DaffFailureAction, Action { + readonly type = 'mockLoadFailure'; + constructor(public payload: Array) {} +} + +type Actions = MockLoadAction | MockLoadSuccessAction | MockLoadFailureAction; + +const actionMap: InjectableActionMap = { + [DAFF_DOCS_LOAD]: { + mockLoad: { type: 'mockLoad', transform: identity }, + }, + [DAFF_DOCS_LOAD_SUCCESS]: { + mockLoadSuccess: { type: 'mockLoadSuccess', transform: identity }, + }, + [DAFF_DOCS_LOAD_FAILURE]: { + mockLoadFailure: { type: 'mockLoadFailure', transform: identity }, + }, +}; + +describe('@daffodil/docs/state | getDaffDocsEntitySelectors', () => { + let store: Store; + + let docsItemFactory: DaffDocsItemFactory; + + let mockDocsItem: DaffDocsItem; + let docsId: DaffDocsItem['id']; + + const { + selectAllDocsEntities, + selectDocsEntities, + selectDocsIds, + selectDocsTotal, + } = getDaffDocsEntitySelectors(); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ + [DAFF_DOCS_STORE_FEATURE_KEY]: combineReducers({ + docs: daffDocsReducerFactory(actionMap), + docsEntities: daffDocsEntitiesReducerFactory(actionMap), + }), + }), + ], + }); + + store = TestBed.inject(Store); + docsItemFactory = TestBed.inject(DaffDocsItemFactory); + + mockDocsItem = docsItemFactory.create(); + docsId = mockDocsItem.id; + }); + + describe('selectAllDocsEntities', () => { + it('should initially be an empty array', () => { + const selector = store.pipe(select(selectAllDocsEntities)); + const expected = cold('a', { a: []}); + + expect(selector).toBeObservable(expected); + }); + + describe('when an docs has been loaded', () => { + beforeEach(() => { + store.dispatch(new MockLoadSuccessAction([mockDocsItem])); + }); + + it('should select all of the docsEntities', () => { + const selector = store.pipe(select(selectAllDocsEntities)); + const expected = cold('a', { a: [mockDocsItem]}); + + expect(selector).toBeObservable(expected); + }); + }); + }); + + describe('selectDocsEntities', () => { + it('should initially be an empty object', () => { + const selector = store.pipe(select(selectDocsEntities)); + const expected = cold('a', { a: {}}); + + expect(selector).toBeObservable(expected); + }); + + describe('when an docs has been loaded', () => { + beforeEach(() => { + store.dispatch(new MockLoadSuccessAction([mockDocsItem])); + }); + + it('should select all of the docsEntities', () => { + const selector = store.pipe(select(selectDocsEntities)); + const expected = cold('a', { a: { [docsId]: mockDocsItem }}); + + expect(selector).toBeObservable(expected); + }); + }); + }); + + describe('selectDocsIds', () => { + it('should initially be an empty array', () => { + const selector = store.pipe(select(selectDocsIds)); + const expected = cold('a', { a: []}); + + expect(selector).toBeObservable(expected); + }); + + describe('when an docs has been loaded', () => { + beforeEach(() => { + store.dispatch(new MockLoadSuccessAction([mockDocsItem])); + }); + + it('should select all of the docs IDs', () => { + const selector = store.pipe(select(selectDocsIds)); + const expected = cold('a', { a: [docsId]}); + + expect(selector).toBeObservable(expected); + }); + }); + }); + + describe('selectDocsTotal', () => { + it('should initially be 0', () => { + const selector = store.pipe(select(selectDocsTotal)); + const expected = cold('a', { a: 0 }); + + expect(selector).toBeObservable(expected); + }); + + describe('when an docs has been loaded', () => { + beforeEach(() => { + store.dispatch(new MockLoadSuccessAction([mockDocsItem])); + }); + + it('should select the total number of docsEntities', () => { + const selector = store.pipe(select(selectDocsTotal)); + const expected = cold('a', { a: 1 }); + + expect(selector).toBeObservable(expected); + }); + }); + }); +}); diff --git a/libs/docs/state/src/selectors/entities.selector.ts b/libs/docs/state/src/selectors/entities.selector.ts new file mode 100644 index 0000000000..2463d35b3e --- /dev/null +++ b/libs/docs/state/src/selectors/entities.selector.ts @@ -0,0 +1,69 @@ +import { Dictionary } from '@ngrx/entity'; +import { + createSelector, + defaultMemoize, + MemoizedSelector, +} from '@ngrx/store'; + +import { DaffDocsItem } from '@daffodil/docs-utils'; + +import { getDaffDocsReducersStateSelector } from './feature.selector'; +import { + DaffDocsStateRootSlice, + daffGetDocsAdapter, + DaffDocsEntityState, +} from '../reducers/public_api'; + +export interface DaffDocsEntitySelectors { + selectDocsEntitiesState: MemoizedSelector, DaffDocsEntityState>; + /** + * Selector for docs IDs. + */ + selectDocsIds: MemoizedSelector, Array>; + /** + * Selector for docs entities. + */ + selectDocsEntities: MemoizedSelector, Dictionary>; + /** + * Selector for all docsEntities. + */ + selectAllDocsEntities: MemoizedSelector, Array>; + /** + * Selector for total number of docsEntities. + */ + selectDocsTotal: MemoizedSelector, number>; + selectDocs: (docsId: T['id']) => MemoizedSelector, T | null>; + +} + +const createDocsEntitySelectors = () => { + const { selectDocsFeatureState } = getDaffDocsReducersStateSelector(); + const selectDocsEntitiesState = createSelector( + selectDocsFeatureState, + state => state.docsEntities, + ); + const { selectIds, selectEntities, selectAll, selectTotal } = daffGetDocsAdapter().getSelectors(selectDocsEntitiesState); + + const selectDocs: (docsId: T['id']) => MemoizedSelector, T | null> = + defaultMemoize((docsId: T['id']) => createSelector( + selectEntities, + (docsEntities: Dictionary) => docsEntities[docsId] || null, + )).memoized; + + return { + selectDocsEntitiesState, + selectDocsIds: selectIds, + selectDocsEntities: selectEntities, + selectAllDocsEntities: selectAll, + selectDocsTotal: selectTotal, + + selectDocs, + }; +}; + +export const getDaffDocsEntitySelectors = (() => { + let cache: any; + return (): DaffDocsEntitySelectors => + cache = cache || createDocsEntitySelectors(); +})(); + diff --git a/libs/docs/state/src/selectors/feature.selector.ts b/libs/docs/state/src/selectors/feature.selector.ts new file mode 100644 index 0000000000..42cfaa52cc --- /dev/null +++ b/libs/docs/state/src/selectors/feature.selector.ts @@ -0,0 +1,24 @@ +import { + createFeatureSelector, + MemoizedSelector, +} from '@ngrx/store'; + +import { DaffDocsItem } from '@daffodil/docs-utils'; + +import { + DaffDocsStateRootSlice, + DaffDocsReducersState, + DAFF_DOCS_STORE_FEATURE_KEY, +} from '../reducers/public_api'; + +export interface DaffDocsFeatureSelector { + selectDocsFeatureState: MemoizedSelector, DaffDocsReducersState>; +} + +export const getDaffDocsReducersStateSelector = (() => { + let cache: any; + return (): DaffDocsFeatureSelector => + cache = cache || { + selectDocsFeatureState: createFeatureSelector>(DAFF_DOCS_STORE_FEATURE_KEY), + }; +})(); diff --git a/libs/docs/state/src/selectors/public_api.ts b/libs/docs/state/src/selectors/public_api.ts new file mode 100644 index 0000000000..ddcc409c9f --- /dev/null +++ b/libs/docs/state/src/selectors/public_api.ts @@ -0,0 +1,3 @@ +export * from './all.selector'; +export * from './selector'; +export * from './entities.selector'; diff --git a/libs/docs/state/src/selectors/selector.ts b/libs/docs/state/src/selectors/selector.ts new file mode 100644 index 0000000000..c91e30696c --- /dev/null +++ b/libs/docs/state/src/selectors/selector.ts @@ -0,0 +1,39 @@ +import { + createSelector, + MemoizedSelector, +} from '@ngrx/store'; + +import { + daffOperationStateSelectorFactory, + DaffOperationStateSelectors, +} from '@daffodil/core/state'; +import { DaffDocsItem } from '@daffodil/docs-utils'; + +import { getDaffDocsReducersStateSelector } from './feature.selector'; +import { + DaffDocsStateRootSlice, + DaffDocsReducerState, +} from '../reducers/public_api'; + +export interface DaffDocsSelectors extends DaffOperationStateSelectors, DaffDocsReducerState> { + selectDocsState: MemoizedSelector; +} + +const createDocsSelectors = () => { + const { selectDocsFeatureState } = getDaffDocsReducersStateSelector(); + const selectDocsState = createSelector( + selectDocsFeatureState, + state => state.docs, + ); + + return { + ...daffOperationStateSelectorFactory(selectDocsState), + selectDocsState, + }; +}; + +export const getDocsSelectors = (() => { + let cache: any; + return (): DaffDocsSelectors => + cache = cache || createDocsSelectors(); +})(); diff --git a/libs/docs/state/src/state.module.ts b/libs/docs/state/src/state.module.ts new file mode 100644 index 0000000000..db7908052d --- /dev/null +++ b/libs/docs/state/src/state.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { StoreModule } from '@ngrx/store'; + +import { DAFF_DOCS_STORE_FEATURE_KEY } from './reducers/public_api'; +import { DAFF_DOCS_REDUCERS } from './reducers/token/reducers.token'; + +@NgModule({ + imports: [ + StoreModule.forFeature(DAFF_DOCS_STORE_FEATURE_KEY, DAFF_DOCS_REDUCERS), + ], +}) +export class DaffDocsStateModule {} diff --git a/libs/docs/state/testing/ng-package.json b/libs/docs/state/testing/ng-package.json new file mode 100644 index 0000000000..7dcb29e536 --- /dev/null +++ b/libs/docs/state/testing/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-entrypoint.schema.json", + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/docs/state/testing/src/index.ts b/libs/docs/state/testing/src/index.ts new file mode 100644 index 0000000000..4aaf8f92ed --- /dev/null +++ b/libs/docs/state/testing/src/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/libs/docs/state/testing/src/mock.facade.ts b/libs/docs/state/testing/src/mock.facade.ts new file mode 100644 index 0000000000..446aadd052 --- /dev/null +++ b/libs/docs/state/testing/src/mock.facade.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@angular/core'; +import { Dictionary } from '@ngrx/entity'; +import { Action } from '@ngrx/store'; +import { BehaviorSubject } from 'rxjs'; + +import { + DaffState, + DaffStateError, +} from '@daffodil/core/state'; +import { DaffDocsFacadeInterface } from '@daffodil/docs/state'; +import { DaffDocsItem } from '@daffodil/docs-utils'; + +/** + * @inheritdoc + */ +@Injectable({ providedIn: 'root' }) +export class MockDaffDocsFacade implements DaffDocsFacadeInterface { + loading$ = new BehaviorSubject(false); + errors$ = new BehaviorSubject([]); + loadingState$ = new BehaviorSubject(DaffState.Stable); + resolving$ = new BehaviorSubject(false); + mutating$ = new BehaviorSubject(false); + hasErrors$ = new BehaviorSubject(false); + + docsItems$ = new BehaviorSubject>([]); + docsEntities$ = new BehaviorSubject>({}); + docsIds$ = new BehaviorSubject([]); + docsCount$ = new BehaviorSubject(0); + + getDocs$(docsId: DaffDocsItem['id']): BehaviorSubject { + return new BehaviorSubject(null); + } + + dispatch(action: Action) {}; +} diff --git a/libs/docs/state/testing/src/public_api.ts b/libs/docs/state/testing/src/public_api.ts new file mode 100644 index 0000000000..1f9e1d26ed --- /dev/null +++ b/libs/docs/state/testing/src/public_api.ts @@ -0,0 +1,2 @@ +export { MockDaffDocsFacade } from './mock.facade'; +export { DaffDocsStateTestingModule } from './testing.module'; diff --git a/libs/docs/state/testing/src/testing.module.ts b/libs/docs/state/testing/src/testing.module.ts new file mode 100644 index 0000000000..870bcf241f --- /dev/null +++ b/libs/docs/state/testing/src/testing.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; + +import { DaffDocsFacade } from '@daffodil/docs/state'; + +import { MockDaffDocsFacade } from './mock.facade'; + +@NgModule({ + providers: [ + { provide: DaffDocsFacade, useExisting: MockDaffDocsFacade }, + ], +}) +export class DaffDocsStateTestingModule {} diff --git a/libs/docs/testing/src/factories/item.factory.ts b/libs/docs/testing/src/factories/item.factory.ts index 971b0ec395..a515e75cff 100644 --- a/libs/docs/testing/src/factories/item.factory.ts +++ b/libs/docs/testing/src/factories/item.factory.ts @@ -24,7 +24,7 @@ import { @Injectable({ providedIn: 'root', }) -export class DaffDocItemFactory extends DaffModelFactory { +export class DaffDocsItemFactory extends DaffModelFactory { constructor( private docFactory: DaffDocFactory, private packageGuideDoc: DaffPackageGuideDocFactory, diff --git a/libs/docs/tsconfig.json b/libs/docs/tsconfig.json index 2ed7fa6b82..b2411f5820 100644 --- a/libs/docs/tsconfig.json +++ b/libs/docs/tsconfig.json @@ -30,6 +30,12 @@ ], "@daffodil/docs/testing": [ "libs/docs/testing/src" + ], + "@daffodil/docs/state": [ + "libs/docs/state/src" + ], + "@daffodil/docs/state/testing": [ + "libs/docs/state/testing/src" ] } }, diff --git a/libs/external-router/src/config.ts b/libs/external-router/src/config.ts index 7852dd0f74..324bf076f3 100644 --- a/libs/external-router/src/config.ts +++ b/libs/external-router/src/config.ts @@ -14,7 +14,7 @@ export const { * Provider function for {@link DAFF_EXTERNAL_ROUTER_CONFIG}. */ provider: provideDaffExternalRouterConfig, -} = createConfigInjectionToken(daffExternalRouterConfigurationDefault, 'DAFF_EXTERNAL_ROUTER_CONFIG'); +} = createConfigInjectionToken(daffExternalRouterConfigurationDefault, 'DAFF_EXTERNAL_ROUTER_CONFIG'); /** * The configuration object for the external router package. diff --git a/libs/search-docs/README.md b/libs/search-docs/README.md new file mode 100644 index 0000000000..5fa3876386 --- /dev/null +++ b/libs/search-docs/README.md @@ -0,0 +1,16 @@ +# @daffodil/search-docs +`@daffodil/search-docs` integrates `@daffodil/docs` with `@daffodil/search` to enable documentation search for daffodil generated docs. + +## Installation +To install `@daffodil/search-docs`, use the following commands in your terminal. + +Install with npm: +```bash +npm install @daffodil/search-docs --save +``` + +Install with yarn: + +```bash +yarn add @daffodil/search-docs +``` diff --git a/libs/search-docs/driver/algolia/ng-package.json b/libs/search-docs/driver/algolia/ng-package.json new file mode 100644 index 0000000000..7dcb29e536 --- /dev/null +++ b/libs/search-docs/driver/algolia/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-entrypoint.schema.json", + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/search-docs/driver/algolia/src/algolia.provider.ts b/libs/search-docs/driver/algolia/src/algolia.provider.ts new file mode 100644 index 0000000000..839933d1c9 --- /dev/null +++ b/libs/search-docs/driver/algolia/src/algolia.provider.ts @@ -0,0 +1,23 @@ +import { makeEnvironmentProviders } from '@angular/core'; + +import { + provideAlgoliaSearchResultGetKind, + provideAlgoliaSearchResultTransforms, + provideDaffAlgoliaSearchDriver, + provideDaffSearchAlgoliaConfig, +} from '@daffodil/search/driver/algolia'; +import { DAFF_SEARCH_DOCS_RESULT_KIND } from '@daffodil/search-docs'; + +import { algoliaSearchDocsResultTransform } from './transforms/result'; + +export const provideAlgoliaSearchDocs = ( + config: Parameters[0], + getSearchResultKind: Parameters[0], +) => makeEnvironmentProviders([ + provideDaffAlgoliaSearchDriver(config), + provideAlgoliaSearchResultTransforms({ + kind: DAFF_SEARCH_DOCS_RESULT_KIND, + transform: algoliaSearchDocsResultTransform, + }), + provideAlgoliaSearchResultGetKind(getSearchResultKind), +]); diff --git a/libs/search-docs/driver/algolia/src/index.ts b/libs/search-docs/driver/algolia/src/index.ts new file mode 100644 index 0000000000..4aaf8f92ed --- /dev/null +++ b/libs/search-docs/driver/algolia/src/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/libs/search-docs/driver/algolia/src/public_api.ts b/libs/search-docs/driver/algolia/src/public_api.ts new file mode 100644 index 0000000000..7cf41c3291 --- /dev/null +++ b/libs/search-docs/driver/algolia/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './algolia.provider'; +export * from './transforms/result'; diff --git a/libs/search-docs/driver/algolia/src/transforms/result.ts b/libs/search-docs/driver/algolia/src/transforms/result.ts new file mode 100644 index 0000000000..319abbed78 --- /dev/null +++ b/libs/search-docs/driver/algolia/src/transforms/result.ts @@ -0,0 +1,12 @@ +import { DaffDocsItem } from '@daffodil/docs-utils'; +import { AlgoliaSearchResultTransform } from '@daffodil/search/driver/algolia'; +import { + DAFF_SEARCH_DOCS_RESULT_KIND, + DaffSearchDocsResult, +} from '@daffodil/search-docs'; + +export const algoliaSearchDocsResultTransform: AlgoliaSearchResultTransform = (doc): DaffSearchDocsResult => ({ + ...doc, + url: doc.path, + kind: 'kind' in doc ? doc.kind : DAFF_SEARCH_DOCS_RESULT_KIND, +}); diff --git a/libs/search-docs/driver/ng-package.json b/libs/search-docs/driver/ng-package.json new file mode 100644 index 0000000000..0f621f8520 --- /dev/null +++ b/libs/search-docs/driver/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json", + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/search-docs/driver/src/index.ts b/libs/search-docs/driver/src/index.ts new file mode 100644 index 0000000000..4aaf8f92ed --- /dev/null +++ b/libs/search-docs/driver/src/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/libs/search-docs/driver/src/public_api.ts b/libs/search-docs/driver/src/public_api.ts new file mode 100644 index 0000000000..cb0ff5c3b5 --- /dev/null +++ b/libs/search-docs/driver/src/public_api.ts @@ -0,0 +1 @@ +export {}; diff --git a/libs/search-docs/karma.conf.js b/libs/search-docs/karma.conf.js new file mode 100644 index 0000000000..44032a8bab --- /dev/null +++ b/libs/search-docs/karma.conf.js @@ -0,0 +1,10 @@ +baseConfiguration = require('../../tools/karma/karma.conf'); + +module.exports = function (config) { + baseConfiguration(config); + config.set({ + coverageIstanbulReporter: { + dir: require('path').join(__dirname, '../../coverage/libs/search-docs'), + }, + }); +}; diff --git a/libs/search-docs/ng-package.json b/libs/search-docs/ng-package.json new file mode 100644 index 0000000000..a50c5e3c3a --- /dev/null +++ b/libs/search-docs/ng-package.json @@ -0,0 +1,8 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/search-docs", + "deleteDestPath": false, + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/search-docs/ng-package.prod.json b/libs/search-docs/ng-package.prod.json new file mode 100644 index 0000000000..5c0fcc4646 --- /dev/null +++ b/libs/search-docs/ng-package.prod.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/search-docs", + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/search-docs/package.json b/libs/search-docs/package.json new file mode 100644 index 0000000000..ab39d9604a --- /dev/null +++ b/libs/search-docs/package.json @@ -0,0 +1,52 @@ +{ + "name": "@daffodil/search-docs", + "nx": { + "targets": { + "build": { + "outputs": ["{workspaceRoot}/dist/search-docs"] + } + } + }, + "version": "0.0.0-PLACEHOLDER", + "description": "Interfaces built for daffodil/state", + "repository": { + "type": "git", + "url": "https://github.com/graycoreio/daffodil" + }, + "author": "Graycore LLC", + "license": "MIT", + "bugs": { + "url": "https://github.com/graycoreio/daffodil/issues" + }, + "publishConfig": { + "directory": "../../dist/search-docs" + }, + "scripts": { + "build": "ng build search-docs --configuration production", + "lint": "cd ../.. && ng lint search-docs", + "lint:fix": "npm run lint -- --fix", + "test": "ng test search-docs --watch=false --browsers=ChromeHeadless", + "publish": "cd ../../dist/search-docs && npm publish --access=public" + }, + "homepage": "https://github.com/graycoreio/daffodil", + "peerDependencies": { + "@angular/common": "0.0.0-PLACEHOLDER", + "@angular/core": "0.0.0-PLACEHOLDER", + "@daffodil/core": "0.0.0-PLACEHOLDER", + "@daffodil/docs": "0.0.0-PLACEHOLDER", + "@daffodil/search": "0.0.0-PLACEHOLDER", + "rxjs": "0.0.0-PLACEHOLDER" + }, + "optionalDependencies": { + "@apollo/client": "0.0.0-PLACEHOLDER", + "@daffodil/driver": "0.0.0-PLACEHOLDER", + "apollo-angular": "0.0.0-PLACEHOLDER", + "@faker-js/faker": "0.0.0-PLACEHOLDER" + }, + "devDependencies": { + "@daffodil/core": "0.0.0-PLACEHOLDER", + "@daffodil/docs": "0.0.0-PLACEHOLDER", + "@daffodil/search": "0.0.0-PLACEHOLDER", + "@daffodil/driver": "0.0.0-PLACEHOLDER" + } +} diff --git a/libs/search-docs/src/constants/public_api.ts b/libs/search-docs/src/constants/public_api.ts new file mode 100644 index 0000000000..6454a48cd4 --- /dev/null +++ b/libs/search-docs/src/constants/public_api.ts @@ -0,0 +1 @@ +export { DAFF_SEARCH_DOCS_RESULT_KIND } from './search-result-kind.const'; diff --git a/libs/search-docs/src/constants/search-result-kind.const.ts b/libs/search-docs/src/constants/search-result-kind.const.ts new file mode 100644 index 0000000000..5eef612b1b --- /dev/null +++ b/libs/search-docs/src/constants/search-result-kind.const.ts @@ -0,0 +1,4 @@ +/** + * The kind identifier for product search results. + */ +export const DAFF_SEARCH_DOCS_RESULT_KIND = 'docs'; diff --git a/libs/search-docs/src/index.ts b/libs/search-docs/src/index.ts new file mode 100644 index 0000000000..4aaf8f92ed --- /dev/null +++ b/libs/search-docs/src/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/libs/search-docs/src/models/public_api.ts b/libs/search-docs/src/models/public_api.ts new file mode 100644 index 0000000000..bc44d1ad94 --- /dev/null +++ b/libs/search-docs/src/models/public_api.ts @@ -0,0 +1 @@ +export { DaffSearchDocsResult } from './search-result.type'; diff --git a/libs/search-docs/src/models/search-result.type.ts b/libs/search-docs/src/models/search-result.type.ts new file mode 100644 index 0000000000..69f5216613 --- /dev/null +++ b/libs/search-docs/src/models/search-result.type.ts @@ -0,0 +1,14 @@ +import { + DaffDocKind, + DaffDocsItem, +} from '@daffodil/docs-utils'; +import { DaffSearchResult } from '@daffodil/search'; + +import { DAFF_SEARCH_DOCS_RESULT_KIND } from '../constants/public_api'; + +/** + * An extension of a {@link DaffSearchResult} for docs. + */ +export type DaffSearchDocsResult = DaffSearchResult & T & { + kind: typeof DAFF_SEARCH_DOCS_RESULT_KIND | DaffDocKind; +}; diff --git a/libs/search-docs/src/public_api.ts b/libs/search-docs/src/public_api.ts new file mode 100644 index 0000000000..d619072cd6 --- /dev/null +++ b/libs/search-docs/src/public_api.ts @@ -0,0 +1,2 @@ +export * from './constants/public_api'; +export * from './models/public_api'; diff --git a/libs/search-docs/state/ng-package.json b/libs/search-docs/state/ng-package.json new file mode 100644 index 0000000000..0f621f8520 --- /dev/null +++ b/libs/search-docs/state/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json", + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/search-docs/state/src/actions/collection.actions.ts b/libs/search-docs/state/src/actions/collection.actions.ts new file mode 100644 index 0000000000..9a4b877fe6 --- /dev/null +++ b/libs/search-docs/state/src/actions/collection.actions.ts @@ -0,0 +1,123 @@ +import { + DaffCollectionRequest, + DaffFilterRequest, + DaffFilterToggleRequest, +} from '@daffodil/core'; +import { + DaffCollectionChangePageSize, + DaffCollectionChangeCurrentPage, + DaffCollectionChangeSortingOption, + DaffCollectionReplaceFilters, + DaffCollectionApplyFilters, + DaffCollectionRemoveFilters, + DaffCollectionToggleFilter, + DaffCollectionClearFilters, +} from '@daffodil/core/state'; + +/** + * The search action types enum. + */ +export enum DaffSearchDocsCollectionActionTypes { + SearchDocsReplaceFiltersAction = '[@daffodil/search-docs] Search Docs Replace Filters Action', + SearchDocsApplyFiltersAction = '[@daffodil/search-docs] Search Docs Apply Filters Action', + SearchDocsRemoveFiltersAction = '[@daffodil/search-docs] Search Docs Remove Filters Action', + SearchDocsClearFiltersAction = '[@daffodil/search-docs] Search Docs Clear Filters Action', + SearchDocsToggleFiltersAction = '[@daffodil/search-docs] Search Docs Toggle Filters Action', + SearchDocsChangePageSizeAction = '[@daffodil/search-docs] Search Docs Change Page Size Action', + SearchDocsChangeCurrentPageAction = '[@daffodil/search-docs] Search Docs Change Current Page Action', + SearchDocsChangeSortingOptionAction = '[@daffodil/search-docs] Search Docs Change Sorting Option Action', +} + +/** + * @inheritdoc + * @role action + */ +export class DaffSearchDocsCollectionReplaceFilters implements DaffCollectionReplaceFilters { + readonly type = DaffSearchDocsCollectionActionTypes.SearchDocsReplaceFiltersAction; + + constructor(public filters: DaffFilterRequest[]) {} +} + +/** + * @inheritdoc + * @role action + */ +export class DaffSearchDocsCollectionApplyFilters implements DaffCollectionApplyFilters { + readonly type = DaffSearchDocsCollectionActionTypes.SearchDocsApplyFiltersAction; + + constructor(public filters: DaffFilterRequest[]) {} +} + +/** + * @inheritdoc + * @role action + */ +export class DaffSearchDocsCollectionRemoveFilters implements DaffCollectionRemoveFilters { + readonly type = DaffSearchDocsCollectionActionTypes.SearchDocsRemoveFiltersAction; + + constructor(public filters: DaffFilterRequest[]) {} +} + +/** + * @inheritdoc + * @role action + */ +export class DaffSearchDocsCollectionClearFilters implements DaffCollectionClearFilters { + readonly type = DaffSearchDocsCollectionActionTypes.SearchDocsClearFiltersAction; +} + +/** + * @inheritdoc + * @role action + */ +export class DaffSearchDocsCollectionToggleFilter implements DaffCollectionToggleFilter { + readonly type = DaffSearchDocsCollectionActionTypes.SearchDocsToggleFiltersAction; + + constructor(public filter: DaffFilterToggleRequest) {} +} + +/** + * @inheritdoc + * @role action + */ +export class DaffSearchDocsCollectionChangePageSize implements DaffCollectionChangePageSize { + readonly type = DaffSearchDocsCollectionActionTypes.SearchDocsChangePageSizeAction; + + constructor(public pageSize: number) {} +} + +/** + * @inheritdoc + * @role action + */ +export class DaffSearchDocsCollectionChangeCurrentPage implements DaffCollectionChangeCurrentPage { + readonly type = DaffSearchDocsCollectionActionTypes.SearchDocsChangeCurrentPageAction; + + constructor(public currentPage: number) {} +} + +/** + * @inheritdoc + * @role action + */ +export class DaffSearchDocsCollectionChangeSortingOption implements DaffCollectionChangeSortingOption { + readonly type = DaffSearchDocsCollectionActionTypes.SearchDocsChangeSortingOptionAction; + + constructor(public sort: { + option: DaffCollectionRequest['appliedSortOption']; + direction: DaffCollectionRequest['appliedSortDirection']; + }) {} +} + +/** + * A union of the search docs action types. + */ +export type DaffSearchDocsCollectionActions = + | DaffSearchDocsCollectionReplaceFilters + | DaffSearchDocsCollectionApplyFilters + | DaffSearchDocsCollectionRemoveFilters + | DaffSearchDocsCollectionClearFilters + | DaffSearchDocsCollectionToggleFilter + | DaffSearchDocsCollectionChangePageSize + | DaffSearchDocsCollectionChangeCurrentPage + | DaffSearchDocsCollectionChangeSortingOption; diff --git a/libs/search-docs/state/src/core.module.ts b/libs/search-docs/state/src/core.module.ts new file mode 100644 index 0000000000..52e8965bcc --- /dev/null +++ b/libs/search-docs/state/src/core.module.ts @@ -0,0 +1,51 @@ +import { NgModule } from '@angular/core'; +import { + combineReducers, + StoreModule, +} from '@ngrx/store'; + +import { + DaffDocsStateModule, + provideDaffDocsLoadSuccessActions, +} from '@daffodil/docs/state'; +import { + DaffSearchStateModule, + DaffSearchActionTypes, + DaffSearchActions, + daffSearchProvideExtraReducers, +} from '@daffodil/search/state'; +import { + DAFF_SEARCH_DOCS_RESULT_KIND, + DaffSearchDocsResult, +} from '@daffodil/search-docs'; + +import { + DAFF_SEARCH_DOCS_STORE_FEATURE_KEY, + daffSearchDocsReducers, + daffSearchDocsCollectionSearchReducers, +} from './reducers/public_api'; + +@NgModule({ + imports: [ + StoreModule.forFeature(DAFF_SEARCH_DOCS_STORE_FEATURE_KEY, daffSearchDocsReducers), + DaffDocsStateModule, + DaffSearchStateModule, + ], + providers: [ + daffSearchProvideExtraReducers(combineReducers(daffSearchDocsCollectionSearchReducers)), + // provideDaffDocsExtraReducers(combineReducers(daffSearchDocsDocsReducers)), + provideDaffDocsLoadSuccessActions>( + { + type: DaffSearchActionTypes.SearchLoadSuccessAction, + transform: (action) => ({ payload: action.payload.collection[DAFF_SEARCH_DOCS_RESULT_KIND] || [] }), + }, + ), + provideDaffDocsLoadSuccessActions>( + { + type: DaffSearchActionTypes.SearchIncrementalSuccessAction, + transform: (action) => ({ payload: action.payload[DAFF_SEARCH_DOCS_RESULT_KIND] || [] }), + }, + ), + ], +}) +export class DaffSearchDocsStateCoreModule {} diff --git a/libs/search-docs/state/src/effects/collection.effects.spec.ts b/libs/search-docs/state/src/effects/collection.effects.spec.ts new file mode 100644 index 0000000000..9c3699b9d7 --- /dev/null +++ b/libs/search-docs/state/src/effects/collection.effects.spec.ts @@ -0,0 +1,358 @@ +import { TestBed } from '@angular/core/testing'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Action } from '@ngrx/store'; +import { + Observable, + of, +} from 'rxjs'; +import { TestScheduler } from 'rxjs/testing'; + +import { + DaffError, + DaffInheritableError, + DaffSortDirectionEnum, + daffFilterArrayToDict, + DaffFilterRequest, + daffFiltersToRequests, + DaffFilterToggleRequest, +} from '@daffodil/core'; +import { + DaffCollectionChangeCurrentPage, + DaffCollectionChangePageSize, + DaffCollectionChangeSortingOption, + daffTransformErrorToStateError, + DaffCollectionApplyFilters, + DaffCollectionClearFilters, + DaffCollectionRemoveFilters, + DaffCollectionReplaceFilters, + DaffCollectionToggleFilter, +} from '@daffodil/core/state'; +import { MockDaffCollectionFacade } from '@daffodil/core/state/testing'; +import { + DaffCollectionMetadataFactory, + DaffFilterFactory, + DaffFilterRequestFactory, + DaffFilterToggleRequestFactory, +} from '@daffodil/core/testing'; +import { DaffDocsStateTestingModule } from '@daffodil/docs/state/testing'; +import { DaffSearchDriver } from '@daffodil/search/driver'; +import { DaffSearchTestingDriverModule } from '@daffodil/search/driver/testing'; +import { + DaffSearchLoadFailure, + DaffSearchLoadSuccess, +} from '@daffodil/search/state'; +import { + DaffSearchStateTestingModule, + MockDaffSearchFacade, +} from '@daffodil/search/state/testing'; +import { DaffSearchDocsDriverResponse } from '@daffodil/search-docs/driver'; +import { DaffSearchDocsDriverInterface } from '@daffodil/search-docs/driver'; +import { DaffSearchDocsTestingDriverModule } from '@daffodil/search-docs/driver/testing'; +import { DaffSearchDocsCollectionActionTypes } from '@daffodil/search-docs/state'; +import { DaffSearchDocsStateTestingModule } from '@daffodil/search-docs/state/testing'; + +import { DaffSearchDocsCollectionEffects } from './collection.effects'; + +class MockError extends DaffInheritableError implements DaffError { + code = 'code'; + + constructor() { + super('message'); + } +} + +describe('@daffodil/docs/state | DaffDocsCollectionEffects', () => { + let actions$: Observable; + let effects: DaffSearchDocsCollectionEffects; + let collectionFacade: MockDaffCollectionFacade; + let facade: MockDaffSearchFacade; + let driverSpy: jasmine.Spy; + + let docsCollectionMetadataFactory: DaffCollectionMetadataFactory; + let filterFactory: DaffFilterFactory; + let filterRequestFactory: DaffFilterRequestFactory; + let filterToggleRequestFactory: DaffFilterToggleRequestFactory; + let query: string; + + const testDriverSuccess = (cb: () => Action) => { + describe('throttling the request', () => { + it('should call immediately, but throttle subsequent events within a specified timeframe, firing off the last event after throttle', () => { + const testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + + const stubCollectionMetadata = docsCollectionMetadataFactory.create({ + filters: daffFilterArrayToDict(filterFactory.createMany(3)), + }); + const response: DaffSearchDocsDriverResponse = { + metadata: stubCollectionMetadata, + collection: {}, + }; + + driverSpy.and.returnValue(of(response)); + + collectionFacade.metadata$.next(stubCollectionMetadata); + + testScheduler.run(({ hot, expectObservable }) => { + actions$ = hot('--a-a-a-a-a', { a: cb() }); + + const expectedMarble = '--a 299ms a'; + const expectedValue = { + a: new DaffSearchLoadSuccess(response), + }; + + expectObservable( + effects.update$(300, testScheduler), + ).toBe(expectedMarble, expectedValue); + }); + + expect(driverSpy).toHaveBeenCalledWith(query, { + appliedSortOption: stubCollectionMetadata.appliedSortOption, + appliedSortDirection: stubCollectionMetadata.appliedSortDirection, + currentPage: stubCollectionMetadata.currentPage, + pageSize: stubCollectionMetadata.pageSize, + filterRequests: daffFiltersToRequests(stubCollectionMetadata.filters), + }); + }); + }); + + describe('and the driver call succeeds', () => { + it('should call the driver with filter requests merged with state', () => { + const testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + + const stubCollectionMetadata = docsCollectionMetadataFactory.create({ + filters: daffFilterArrayToDict(filterFactory.createMany(3)), + }); + const response: DaffSearchDocsDriverResponse = { + metadata: stubCollectionMetadata, + collection: {}, + }; + + driverSpy.and.returnValue(of(response)); + + collectionFacade.metadata$.next(stubCollectionMetadata); + + testScheduler.run(({ hot, expectObservable }) => { + actions$ = hot('--a', { a: cb() }); + + const expectedMarble = '--a'; + const expectedValue = { + a: new DaffSearchLoadSuccess(response), + }; + + expectObservable( + effects.update$(0, testScheduler), + ).toBe(expectedMarble, expectedValue); + }); + + expect(driverSpy).toHaveBeenCalledWith(query, { + appliedSortOption: stubCollectionMetadata.appliedSortOption, + appliedSortDirection: stubCollectionMetadata.appliedSortDirection, + currentPage: stubCollectionMetadata.currentPage, + pageSize: stubCollectionMetadata.pageSize, + filterRequests: daffFiltersToRequests(stubCollectionMetadata.filters), + }); + }); + }); + }; + + const testDriverFailure = (cb: () => Action) => { + describe('and the driver call fails', () => { + it('should emit DaffCategoryPageLoadFailure with the transformed error', () => { + const testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected); + }); + + const stubCollectionMetadata = docsCollectionMetadataFactory.create({ + filters: daffFilterArrayToDict(filterFactory.createMany(3)), + }); + + collectionFacade.metadata$.next(stubCollectionMetadata); + + testScheduler.run(({ hot, expectObservable }) => { + actions$ = hot('--a', { a: cb() }); + + const error = new MockError(); + driverSpy.and.returnValue(hot('#', {}, error)); + + const expectedMarble = '--(a)'; + const expectedValue = { + a: new DaffSearchLoadFailure(daffTransformErrorToStateError(error)), + }; + + expectObservable( + effects.update$(0, testScheduler), + ).toBe(expectedMarble, expectedValue); + }); + + expect(driverSpy).toHaveBeenCalledWith(query, { + appliedSortOption: stubCollectionMetadata.appliedSortOption, + appliedSortDirection: stubCollectionMetadata.appliedSortDirection, + currentPage: stubCollectionMetadata.currentPage, + pageSize: stubCollectionMetadata.pageSize, + filterRequests: daffFiltersToRequests(stubCollectionMetadata.filters), + }); + }); + }); + }; + + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + DaffSearchStateTestingModule, + DaffDocsStateTestingModule, + DaffSearchDocsStateTestingModule, + DaffSearchTestingDriverModule.forRoot(), + DaffSearchDocsTestingDriverModule.forRoot(), + ], + providers: [ + DaffSearchDocsCollectionEffects, + provideMockActions(() => actions$), + ], + }); + + collectionFacade = TestBed.inject(MockDaffCollectionFacade); + facade = TestBed.inject(MockDaffSearchFacade); + effects = TestBed.inject(DaffSearchDocsCollectionEffects); + + driverSpy = spyOn(TestBed.inject(DaffSearchDriver), 'search'); + + docsCollectionMetadataFactory = TestBed.inject(DaffCollectionMetadataFactory); + filterFactory = TestBed.inject(DaffFilterFactory); + filterRequestFactory = TestBed.inject(DaffFilterRequestFactory); + filterToggleRequestFactory = TestBed.inject(DaffFilterToggleRequestFactory); + + query = 'query'; + facade.recent$.next([query]); + }); + + it('should be created', () => { + expect(effects).toBeTruthy(); + }); + + describe('when replaceFilters is triggered', () => { + let filterRequest: DaffFilterRequest; + let action: DaffCollectionReplaceFilters; + + beforeEach(() => { + filterRequest = filterRequestFactory.create(); + action = { + type: DaffSearchDocsCollectionActionTypes.SearchDocsReplaceFiltersAction, + filters: [filterRequest], + }; + }); + + testDriverSuccess(() => action); + }); + + describe('when applyFilters is triggered', () => { + let filterRequest: DaffFilterRequest; + let action: DaffCollectionApplyFilters; + + + beforeEach(() => { + filterRequest = filterRequestFactory.create(); + action = { + type: DaffSearchDocsCollectionActionTypes.SearchDocsReplaceFiltersAction, + filters: [filterRequest], + }; + }); + + testDriverSuccess(() => action); + }); + + describe('when clearFilters is triggered', () => { + let action: DaffCollectionClearFilters; + + + beforeEach(() => { + action = { + type: DaffSearchDocsCollectionActionTypes.SearchDocsClearFiltersAction, + }; + }); + + testDriverSuccess(() => action); + }); + + describe('when removeFilters is triggered', () => { + let filterRequest: DaffFilterRequest; + let action: DaffCollectionRemoveFilters; + + + beforeEach(() => { + filterRequest = filterRequestFactory.create(); + action = { + type: DaffSearchDocsCollectionActionTypes.SearchDocsRemoveFiltersAction, + filters: [filterRequest], + };; + }); + + testDriverSuccess(() => action); + }); + + describe('when toggleFilter is triggered', () => { + let toggleRequest: DaffFilterToggleRequest; + let action: DaffCollectionToggleFilter; + + beforeEach(() => { + toggleRequest = filterToggleRequestFactory.create(); + action = { + type: DaffSearchDocsCollectionActionTypes.SearchDocsReplaceFiltersAction, + filter: toggleRequest, + }; + }); + + testDriverSuccess(() => action); + }); + + describe('when changePageSize is triggered', () => { + let toggleRequest: DaffFilterToggleRequest; + let action: DaffCollectionChangePageSize; + + beforeEach(() => { + toggleRequest = filterToggleRequestFactory.create(); + action = { + type: DaffSearchDocsCollectionActionTypes.SearchDocsReplaceFiltersAction, + pageSize: 5, + }; + }); + + testDriverSuccess(() => action); + }); + + describe('when changeCurrentPage is triggered', () => { + let toggleRequest: DaffFilterToggleRequest; + let action: DaffCollectionChangeCurrentPage; + + beforeEach(() => { + toggleRequest = filterToggleRequestFactory.create(); + action = { + type: DaffSearchDocsCollectionActionTypes.SearchDocsReplaceFiltersAction, + currentPage: 5, + }; + }); + + testDriverSuccess(() => action); + }); + + describe('when changeSorting is triggered', () => { + let toggleRequest: DaffFilterToggleRequest; + let action: DaffCollectionChangeSortingOption; + + beforeEach(() => { + toggleRequest = filterToggleRequestFactory.create(); + action = { + type: DaffSearchDocsCollectionActionTypes.SearchDocsReplaceFiltersAction, + sort: { + option: 'option', + direction: DaffSortDirectionEnum.Ascending, + }, + }; + }); + + testDriverSuccess(() => action); + }); +}); diff --git a/libs/search-docs/state/src/effects/collection.effects.ts b/libs/search-docs/state/src/effects/collection.effects.ts new file mode 100644 index 0000000000..b5a7d5d204 --- /dev/null +++ b/libs/search-docs/state/src/effects/collection.effects.ts @@ -0,0 +1,92 @@ +import { + Injectable, + Inject, +} from '@angular/core'; +import { + Actions, + createEffect, + ofType, +} from '@ngrx/effects'; +import { + asyncScheduler, + of, +} from 'rxjs'; +import { + switchMap, + map, + catchError, + throttleTime, + withLatestFrom, +} from 'rxjs/operators'; + +import { + DaffCollectionRequest, + DaffError, + daffCollectionBuildRequestFromMetadata, +} from '@daffodil/core'; +import { ErrorTransformer } from '@daffodil/core/state'; +import { + DaffSearchDriver , + DaffSearchDriverInterface, +} from '@daffodil/search/driver'; +import { + DaffSearchPageFacade, + DaffSearchLoadFailure, + DaffSearchLoadSuccess, + DAFF_SEARCH_ERROR_MATCHER, +} from '@daffodil/search/state'; +import { DaffSearchDocsResult } from '@daffodil/search-docs'; + +import { DaffSearchDocsCollectionActionTypes } from '../actions/collection.actions'; +import { DaffSearchDocsCollectionFacade } from '../facades/public_api'; + +export const DAFF_SEARCH_DOCS_COLLECTION_ACTION_TYPES = [ + DaffSearchDocsCollectionActionTypes.SearchDocsApplyFiltersAction, + DaffSearchDocsCollectionActionTypes.SearchDocsRemoveFiltersAction, + DaffSearchDocsCollectionActionTypes.SearchDocsReplaceFiltersAction, + DaffSearchDocsCollectionActionTypes.SearchDocsToggleFiltersAction, + DaffSearchDocsCollectionActionTypes.SearchDocsChangeCurrentPageAction, + DaffSearchDocsCollectionActionTypes.SearchDocsChangePageSizeAction, + DaffSearchDocsCollectionActionTypes.SearchDocsChangeSortingOptionAction, + DaffSearchDocsCollectionActionTypes.SearchDocsClearFiltersAction, +]; + +@Injectable() +export class DaffSearchDocsCollectionEffects< + T extends DaffSearchDocsResult = DaffSearchDocsResult, +> { + constructor( + private actions$: Actions, + private collectionFacade: DaffSearchDocsCollectionFacade, + private searchFacade: DaffSearchPageFacade, + // TODO: should we reference the docs kind driver here? + @Inject(DaffSearchDriver) private driver: DaffSearchDriverInterface, + @Inject(DAFF_SEARCH_ERROR_MATCHER) private errorMatcher: ErrorTransformer, + ) {} + + /** + * Updates the docs collection according to the action. + * It will take the request metedata, including currently + * applied filters, from state, form them into a request, + * and pass that into the {@link DaffDocsCollectionDriverCall} provided to this class. + * + * @param throttleWindow the amount of time to delay when apply/removing filters + * in a sequence. + */ + update$ = createEffect(() => (throttleWindow = 300, scheduler = asyncScheduler) => this.actions$.pipe( + ofType(...DAFF_SEARCH_DOCS_COLLECTION_ACTION_TYPES), + withLatestFrom( + this.collectionFacade.metadata$, + this.searchFacade.recent$, + ), + map(([action, metadata, recent]): [string, DaffCollectionRequest] => [ + recent[0], + daffCollectionBuildRequestFromMetadata(metadata), + ]), + throttleTime(throttleWindow, scheduler, { leading: true, trailing: true }), + switchMap(([recent, request]) => this.driver.search(recent, request).pipe( + map(resp => new DaffSearchLoadSuccess(resp)), + catchError((error: DaffError) => of(new DaffSearchLoadFailure(this.errorMatcher(error)))), + )), + )); +} diff --git a/libs/search-docs/state/src/facades/collection/facade.ts b/libs/search-docs/state/src/facades/collection/facade.ts new file mode 100644 index 0000000000..bd9c726455 --- /dev/null +++ b/libs/search-docs/state/src/facades/collection/facade.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; + +import { + DaffCollectionFacade, + DaffCollectionFacadeInterface, +} from '@daffodil/core/state'; + +import { DaffSearchDocsStateRootSlice } from '../../reducers/public_api'; +import { getSearchDocsCollectionSelectors } from '../../selectors/public_api'; + +@Injectable({ + providedIn: 'root', +}) +export class DaffSearchDocsCollectionFacade extends DaffCollectionFacade implements DaffCollectionFacadeInterface { + constructor( + store: Store, + ) { + super( + store, + getSearchDocsCollectionSelectors(), + ); + } +} diff --git a/libs/search-docs/state/src/facades/public_api.ts b/libs/search-docs/state/src/facades/public_api.ts new file mode 100644 index 0000000000..6e9100b4a9 --- /dev/null +++ b/libs/search-docs/state/src/facades/public_api.ts @@ -0,0 +1,5 @@ +export { DaffSearchDocsFacadeInterface } from './search/search-facade.interface'; +export { DaffSearchDocsFacade } from './search/search.facade'; +export { DaffSearchDocsPageFacade } from './search/page.facade'; +export { DaffSearchDocsIncrementalFacade } from './search/incremental.facade'; +export { DaffSearchDocsCollectionFacade } from './collection/facade'; diff --git a/libs/search-docs/state/src/facades/search/incremental.facade.ts b/libs/search-docs/state/src/facades/search/incremental.facade.ts new file mode 100644 index 0000000000..af1ab872ee --- /dev/null +++ b/libs/search-docs/state/src/facades/search/incremental.facade.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; + +import { DaffSearchDocsFacadeInterface } from './search-facade.interface'; +import { DaffSearchDocsFacade } from './search.facade'; +import { DaffSearchDocsStateRootSlice } from '../../reducers/public_api'; +import { daffSearchDocsGetIncrementalSelectors } from '../../selectors/public_api'; + +/** + * @inheritdoc + * + * A facade for the normal search state. + */ +@Injectable({ + providedIn: 'root', +}) +export class DaffSearchDocsIncrementalFacade extends DaffSearchDocsFacade implements DaffSearchDocsFacadeInterface { + constructor( + store: Store, + ) { + super(store, daffSearchDocsGetIncrementalSelectors()); + } +} diff --git a/libs/search-docs/state/src/facades/search/page.facade.ts b/libs/search-docs/state/src/facades/search/page.facade.ts new file mode 100644 index 0000000000..9e02160e00 --- /dev/null +++ b/libs/search-docs/state/src/facades/search/page.facade.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; + +import { DaffSearchDocsFacadeInterface } from './search-facade.interface'; +import { DaffSearchDocsFacade } from './search.facade'; +import { DaffSearchDocsStateRootSlice } from '../../reducers/public_api'; +import { daffSearchDocsGetPageSelectors } from '../../selectors/public_api'; + +/** + * @inheritdoc + * + * A facade for the normal search state. + */ +@Injectable({ + providedIn: 'root', +}) +export class DaffSearchDocsPageFacade extends DaffSearchDocsFacade implements DaffSearchDocsFacadeInterface { + constructor( + store: Store, + ) { + super(store, daffSearchDocsGetPageSelectors()); + } +} diff --git a/libs/search-docs/state/src/facades/search/search-facade.interface.ts b/libs/search-docs/state/src/facades/search/search-facade.interface.ts new file mode 100644 index 0000000000..202072ec1d --- /dev/null +++ b/libs/search-docs/state/src/facades/search/search-facade.interface.ts @@ -0,0 +1,15 @@ +import { Action } from '@ngrx/store'; +import { Observable } from 'rxjs'; + +import { DaffStoreFacade } from '@daffodil/core/state'; +import { DaffSearchDocsResult } from '@daffodil/search-docs'; + +/** + * Exposes the search state selectors. + */ +export interface DaffSearchDocsFacadeInterface extends DaffStoreFacade { + /** + * The docsEntities returned in the most recent search. + */ + docsResults$: Observable>; +} diff --git a/libs/search-docs/state/src/facades/search/search.facade.spec.ts b/libs/search-docs/state/src/facades/search/search.facade.spec.ts new file mode 100644 index 0000000000..461232cdac --- /dev/null +++ b/libs/search-docs/state/src/facades/search/search.facade.spec.ts @@ -0,0 +1,103 @@ +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + StoreModule, + combineReducers, + Store, +} from '@ngrx/store'; +import { cold } from 'jasmine-marbles'; + +import { + DAFF_DOCS_STORE_FEATURE_KEY, + daffDocsReducers, + DaffDocsGridLoadSuccess, +} from '@daffodil/docs/state'; +import { DaffDocsTestingModule } from '@daffodil/docs/testing'; +import { daffSearchTransformResultsToCollection } from '@daffodil/search'; +import { DaffSearchDriverResponse } from '@daffodil/search/driver'; +import { + DaffSearchLoadSuccess, + daffSearchReducers, + DAFF_SEARCH_STORE_FEATURE_KEY, +} from '@daffodil/search/state'; +import { DaffSearchDocsResult } from '@daffodil/search-docs'; +import { + daffSearchDocsGetPageSelectors, + daffSearchDocsReducers, + DaffSearchDocsStateRootSlice, + DAFF_SEARCH_DOCS_STORE_FEATURE_KEY, +} from '@daffodil/search-docs/state'; +import { DaffSearchDocsResultFactory } from '@daffodil/search-docs/testing'; + +import { DaffSearchDocsFacade } from './search.facade'; + +@Injectable() +export class TestFacade extends DaffSearchDocsFacade { + constructor( + store: Store, + ) { + super(store, daffSearchDocsGetPageSelectors()); + } +} + +describe('@daffodil/search-docs/state | DaffSearchDocsFacade', () => { + let store: Store; + let facade: TestFacade; + let searchResultFactory: DaffSearchDocsResultFactory; + + let mockSearchResult: DaffSearchDocsResult; + let mockSearchResultResponse: DaffSearchDriverResponse; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ + [DAFF_SEARCH_STORE_FEATURE_KEY]: combineReducers(daffSearchReducers), + [DAFF_DOCS_STORE_FEATURE_KEY]: combineReducers(daffDocsReducers), + [DAFF_SEARCH_DOCS_STORE_FEATURE_KEY]: combineReducers(daffSearchDocsReducers), + }), + DaffDocsTestingModule, + ], + providers: [ + TestFacade, + ], + }); + + store = TestBed.inject(Store); + facade = TestBed.inject(TestFacade); + searchResultFactory = TestBed.inject(DaffSearchDocsResultFactory); + + mockSearchResult = searchResultFactory.create(); + mockSearchResultResponse = { + collection: daffSearchTransformResultsToCollection([mockSearchResult]), + metadata: {}, + }; + }); + + it('should be created', () => { + expect(facade).toBeTruthy(); + }); + + it('should be able to dispatch an action to the store', () => { + spyOn(store, 'dispatch'); + const action = { type: 'SOME_TYPE' }; + + facade.dispatch(action); + expect(store.dispatch).toHaveBeenCalledWith(action); + expect(store.dispatch).toHaveBeenCalledTimes(1); + }); + + describe('docsResults$', () => { + it('should initially be an empty array', () => { + const expected = cold('a', { a: []}); + expect(facade.docsResults$).toBeObservable(expected); + }); + + it('should be the docsResults upon a successful load', () => { + const expected = cold('a', { a: [mockSearchResult]}); + store.dispatch(new DaffSearchLoadSuccess(mockSearchResultResponse)); + store.dispatch(new DaffDocsGridLoadSuccess([mockSearchResult])); + expect(facade.docsResults$).toBeObservable(expected); + }); + }); +}); diff --git a/libs/search-docs/state/src/facades/search/search.facade.ts b/libs/search-docs/state/src/facades/search/search.facade.ts new file mode 100644 index 0000000000..ae91b780ac --- /dev/null +++ b/libs/search-docs/state/src/facades/search/search.facade.ts @@ -0,0 +1,35 @@ +import { + Action, + Store, + select, +} from '@ngrx/store'; +import { Observable } from 'rxjs'; + +import { DaffSearchDocsResult } from '@daffodil/search-docs'; + +import { DaffSearchDocsFacadeInterface } from './search-facade.interface'; +import { DaffSearchDocsStateRootSlice } from '../../reducers/public_api'; +import { DaffSearchDocsSelectors } from '../../selectors/public_api'; + + +/** + * @inheritdoc + */ +export abstract class DaffSearchDocsFacade implements DaffSearchDocsFacadeInterface { + docsResults$: Observable>; + + constructor( + private store: Store, + selectors: DaffSearchDocsSelectors, + ) { + const { + selectDocsResults, + } = selectors; + + this.docsResults$ = this.store.pipe(select(selectDocsResults)); + } + + dispatch(action: Action) { + this.store.dispatch(action); + } +} diff --git a/libs/search-docs/state/src/index.ts b/libs/search-docs/state/src/index.ts new file mode 100644 index 0000000000..4aaf8f92ed --- /dev/null +++ b/libs/search-docs/state/src/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/libs/search-docs/state/src/page.module.ts b/libs/search-docs/state/src/page.module.ts new file mode 100644 index 0000000000..93257f0c85 --- /dev/null +++ b/libs/search-docs/state/src/page.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core'; +import { EffectsModule } from '@ngrx/effects'; + +import { DaffSearchPageStateModule } from '@daffodil/search/state'; + +import { DaffSearchDocsStateCoreModule } from './core.module'; +import { DaffSearchDocsCollectionEffects } from './effects/collection.effects'; + +@NgModule({ + imports: [ + DaffSearchDocsStateCoreModule, + DaffSearchPageStateModule, + EffectsModule.forFeature([ + DaffSearchDocsCollectionEffects, + ]), + ], +}) +export class DaffSearchDocsPageStateModule {} diff --git a/libs/search-docs/state/src/public_api.ts b/libs/search-docs/state/src/public_api.ts new file mode 100644 index 0000000000..c873d695f1 --- /dev/null +++ b/libs/search-docs/state/src/public_api.ts @@ -0,0 +1,7 @@ +export * from './reducers/public_api'; +export * from './selectors/public_api'; +export * from './facades/public_api'; +export * from './actions/collection.actions'; + +export { DaffSearchDocsStateModule } from './state.module'; +export { DaffSearchDocsPageStateModule } from './page.module'; diff --git a/libs/search-docs/state/src/reducers/collection/public_api.ts b/libs/search-docs/state/src/reducers/collection/public_api.ts new file mode 100644 index 0000000000..72a5058f7c --- /dev/null +++ b/libs/search-docs/state/src/reducers/collection/public_api.ts @@ -0,0 +1,3 @@ +export { daffSearchDocsCollectionSearchReducer } from './search.reducer'; +export { daffSearchDocsCollectionSearchReducers } from './search-reducers'; +export { daffSearchDocsCollectionReducer } from './reducer'; diff --git a/libs/search-docs/state/src/reducers/collection/reducer.ts b/libs/search-docs/state/src/reducers/collection/reducer.ts new file mode 100644 index 0000000000..4f4c22320c --- /dev/null +++ b/libs/search-docs/state/src/reducers/collection/reducer.ts @@ -0,0 +1,64 @@ +import { + daffApplyRequestsToFilters, + daffClearFilters, + DaffCollectionMetadata, + DaffCollectionRequest, + daffRemoveRequestsFromFilters, + daffToggleRequestOnFilters, +} from '@daffodil/core'; +import { + daffCollectionReducerInitialState, + getCollectionStateAdapter, +} from '@daffodil/core/state'; +import { + DaffSearchActions, + DaffSearchActionTypes, +} from '@daffodil/search/state'; + +import { + DaffSearchDocsCollectionActions, + DaffSearchDocsCollectionActionTypes, +} from '../../actions/collection.actions'; + +export const daffSearchDocsCollectionReducer = ( + state: DaffCollectionMetadata = daffCollectionReducerInitialState, + action: DaffSearchActions | DaffSearchDocsCollectionActions, +): DaffCollectionMetadata => { + switch (action.type) { + case DaffSearchActionTypes.SearchLoadAction: + return getCollectionStateAdapter().storeRequest(action.options, state); + + case DaffSearchActionTypes.SearchLoadSuccessAction: + return getCollectionStateAdapter().setMetadata(action.payload.metadata, state); + + case DaffSearchDocsCollectionActionTypes.SearchDocsChangePageSizeAction: + return getCollectionStateAdapter().setPageSize(action.pageSize, state); + + case DaffSearchDocsCollectionActionTypes.SearchDocsChangeCurrentPageAction: + return getCollectionStateAdapter().setCurrentPage(action.currentPage, state); + + case DaffSearchDocsCollectionActionTypes.SearchDocsChangeSortingOptionAction: + return getCollectionStateAdapter().setSort(action.sort.option, action.sort.direction, state); + + case DaffSearchDocsCollectionActionTypes.SearchDocsReplaceFiltersAction: + return getCollectionStateAdapter().setFilters(daffApplyRequestsToFilters(action.filters, daffClearFilters(state.filters)), state); + + case DaffSearchDocsCollectionActionTypes.SearchDocsApplyFiltersAction: + return getCollectionStateAdapter().setFilters(daffApplyRequestsToFilters(action.filters, state.filters), state); + + case DaffSearchDocsCollectionActionTypes.SearchDocsClearFiltersAction: + return getCollectionStateAdapter().setFilters(daffClearFilters(state.filters), state); + + case DaffSearchDocsCollectionActionTypes.SearchDocsRemoveFiltersAction: + return getCollectionStateAdapter().setFilters(daffRemoveRequestsFromFilters(action.filters, state.filters), state); + + case DaffSearchDocsCollectionActionTypes.SearchDocsToggleFiltersAction: + return getCollectionStateAdapter().setFilters(daffToggleRequestOnFilters(action.filter, state.filters), state); + + case DaffSearchActionTypes.SearchLoadFailureAction: + return daffCollectionReducerInitialState; + + default: + return state; + } +}; diff --git a/libs/search-docs/state/src/reducers/collection/search-reducers.ts b/libs/search-docs/state/src/reducers/collection/search-reducers.ts new file mode 100644 index 0000000000..809e55a466 --- /dev/null +++ b/libs/search-docs/state/src/reducers/collection/search-reducers.ts @@ -0,0 +1,11 @@ +import { ActionReducerMap } from '@ngrx/store'; + +import { daffIdentityReducer } from '@daffodil/core/state'; +import { DaffSearchReducersState } from '@daffodil/search/state'; + +import { daffSearchDocsCollectionSearchReducer } from './search.reducer'; + +export const daffSearchDocsCollectionSearchReducers: ActionReducerMap = { + search: daffSearchDocsCollectionSearchReducer, + incremental: daffIdentityReducer, +}; diff --git a/libs/search-docs/state/src/reducers/collection/search.reducer.spec.ts b/libs/search-docs/state/src/reducers/collection/search.reducer.spec.ts new file mode 100644 index 0000000000..dce51e0d17 --- /dev/null +++ b/libs/search-docs/state/src/reducers/collection/search.reducer.spec.ts @@ -0,0 +1,156 @@ +import { + daffSearchInitialState as initialState, + DaffSearchReducerState, +} from '@daffodil/search/state'; +import { + DaffSearchDocsCollectionApplyFilters, + DaffSearchDocsCollectionReplaceFilters, + DaffSearchDocsCollectionRemoveFilters, + DaffSearchDocsCollectionClearFilters, + DaffSearchDocsCollectionToggleFilter, + DaffSearchDocsCollectionChangeCurrentPage, + DaffSearchDocsCollectionChangePageSize, + DaffSearchDocsCollectionChangeSortingOption, +} from '@daffodil/search-docs/state'; + +import { daffSearchDocsCollectionSearchReducer as reducer } from './search.reducer'; + +describe('@daffodil/search-docs-docs/state | daffSearchDocsCollectionSearchReducer', () => { + describe('when an unknown action is triggered', () => { + it('should return the current state', () => { + const action = {}; + + const result = reducer(initialState, action); + + expect(result).toBe(initialState); + }); + }); + + describe('when SearchDocsCollectionApplyFiltersAction is triggered', () => { + let result: DaffSearchReducerState; + + beforeEach(() => { + const searchResultLoadAction = new DaffSearchDocsCollectionApplyFilters([]); + + result = reducer({ + ...initialState, + }, searchResultLoadAction); + }); + + it('sets loading state to true', () => { + expect(result.loading).toEqual(true); + }); + }); + + describe('when SearchDocsCollectionReplaceFiltersAction is triggered', () => { + let result: DaffSearchReducerState; + + beforeEach(() => { + const searchResultLoadAction = new DaffSearchDocsCollectionReplaceFilters([]); + + result = reducer({ + ...initialState, + }, searchResultLoadAction); + }); + + it('sets loading state to true', () => { + expect(result.loading).toEqual(true); + }); + }); + + describe('when SearchDocsCollectionRemoveFiltersAction is triggered', () => { + let result: DaffSearchReducerState; + + beforeEach(() => { + const searchResultLoadAction = new DaffSearchDocsCollectionRemoveFilters([]); + + result = reducer({ + ...initialState, + }, searchResultLoadAction); + }); + + it('sets loading state to true', () => { + expect(result.loading).toEqual(true); + }); + }); + + describe('when SearchDocsCollectionClearFiltersAction is triggered', () => { + let result: DaffSearchReducerState; + + beforeEach(() => { + const searchResultLoadAction = new DaffSearchDocsCollectionClearFilters(); + + result = reducer({ + ...initialState, + }, searchResultLoadAction); + }); + + it('sets loading state to true', () => { + expect(result.loading).toEqual(true); + }); + }); + + describe('when SearchDocsCollectionToggleFilterAction is triggered', () => { + let result: DaffSearchReducerState; + + beforeEach(() => { + const searchResultLoadAction = new DaffSearchDocsCollectionToggleFilter(null); + + result = reducer({ + ...initialState, + }, searchResultLoadAction); + }); + + it('sets loading state to true', () => { + expect(result.loading).toEqual(true); + }); + }); + + describe('when SearchDocsCollectionChangeCurrentPageAction is triggered', () => { + let result: DaffSearchReducerState; + + beforeEach(() => { + const searchResultLoadAction = new DaffSearchDocsCollectionChangeCurrentPage(5); + + result = reducer({ + ...initialState, + }, searchResultLoadAction); + }); + + it('sets loading state to true', () => { + expect(result.loading).toEqual(true); + }); + }); + + describe('when SearchDocsCollectionChangePageSizeAction is triggered', () => { + let result: DaffSearchReducerState; + + beforeEach(() => { + const searchResultLoadAction = new DaffSearchDocsCollectionChangePageSize(5); + + result = reducer({ + ...initialState, + }, searchResultLoadAction); + }); + + it('sets loading state to true', () => { + expect(result.loading).toEqual(true); + }); + }); + + describe('when SearchDocsCollectionChangeSortingOptionAction is triggered', () => { + let result: DaffSearchReducerState; + + beforeEach(() => { + const searchResultLoadAction = new DaffSearchDocsCollectionChangeSortingOption(null); + + result = reducer({ + ...initialState, + }, searchResultLoadAction); + }); + + it('sets loading state to true', () => { + expect(result.loading).toEqual(true); + }); + }); +}); diff --git a/libs/search-docs/state/src/reducers/collection/search.reducer.ts b/libs/search-docs/state/src/reducers/collection/search.reducer.ts new file mode 100644 index 0000000000..356037c78e --- /dev/null +++ b/libs/search-docs/state/src/reducers/collection/search.reducer.ts @@ -0,0 +1,35 @@ +import { + daffSearchInitialState, + DaffSearchReducerState, +} from '@daffodil/search/state'; + +import { + DaffSearchDocsCollectionActions, + DaffSearchDocsCollectionActionTypes, +} from '../../actions/collection.actions'; + +/** + * The reducer for handling apply docs filters actions in the main search state. + */ +export function daffSearchDocsCollectionSearchReducer( + state = daffSearchInitialState, + action: DaffSearchDocsCollectionActions, +): DaffSearchReducerState { + switch (action.type) { + case DaffSearchDocsCollectionActionTypes.SearchDocsApplyFiltersAction: + case DaffSearchDocsCollectionActionTypes.SearchDocsReplaceFiltersAction: + case DaffSearchDocsCollectionActionTypes.SearchDocsRemoveFiltersAction: + case DaffSearchDocsCollectionActionTypes.SearchDocsClearFiltersAction: + case DaffSearchDocsCollectionActionTypes.SearchDocsToggleFiltersAction: + case DaffSearchDocsCollectionActionTypes.SearchDocsChangeCurrentPageAction: + case DaffSearchDocsCollectionActionTypes.SearchDocsChangePageSizeAction: + case DaffSearchDocsCollectionActionTypes.SearchDocsChangeSortingOptionAction: + return { + ...state, + loading: true, + }; + + default: + return state; + } +} diff --git a/libs/search-docs/state/src/reducers/public_api.ts b/libs/search-docs/state/src/reducers/public_api.ts new file mode 100644 index 0000000000..2bb959193d --- /dev/null +++ b/libs/search-docs/state/src/reducers/public_api.ts @@ -0,0 +1,8 @@ +export { daffSearchDocsReducers } from './reducers'; +export { + DaffSearchDocsReducersState, + DaffSearchDocsStateRootSlice, +} from './reducers.interface'; +export { DAFF_SEARCH_DOCS_STORE_FEATURE_KEY } from './store-feature-key'; + +export * from './collection/public_api'; diff --git a/libs/search-docs/state/src/reducers/reducers.interface.ts b/libs/search-docs/state/src/reducers/reducers.interface.ts new file mode 100644 index 0000000000..9625054394 --- /dev/null +++ b/libs/search-docs/state/src/reducers/reducers.interface.ts @@ -0,0 +1,19 @@ +import { DaffCollectionMetadata } from '@daffodil/core'; +import { DaffDocsStateRootSlice } from '@daffodil/docs/state'; +import { DaffSearchStateRootSlice } from '@daffodil/search/state'; + +import { DAFF_SEARCH_DOCS_STORE_FEATURE_KEY } from './store-feature-key'; + +/** + * The feature state for search. + */ +export interface DaffSearchDocsReducersState { + docsCollection: DaffCollectionMetadata; +} + +/** + * The footprint of search feature state in the root application state. + */ +export interface DaffSearchDocsStateRootSlice extends DaffSearchStateRootSlice, DaffDocsStateRootSlice { + [DAFF_SEARCH_DOCS_STORE_FEATURE_KEY]: DaffSearchDocsReducersState; +} diff --git a/libs/search-docs/state/src/reducers/reducers.spec.ts b/libs/search-docs/state/src/reducers/reducers.spec.ts new file mode 100644 index 0000000000..764a6b868e --- /dev/null +++ b/libs/search-docs/state/src/reducers/reducers.spec.ts @@ -0,0 +1,10 @@ +import { daffSearchDocsCollectionReducer } from '@daffodil/search-docs/state'; + +import { daffSearchDocsReducers } from './reducers'; + +describe('@daffodil/search-docs/state | daffSearchDocsReducers', () => { + + it('should return a reducer map with daffSearchDocsCollectionReducer', () => { + expect(daffSearchDocsReducers.docsCollection).toEqual(daffSearchDocsCollectionReducer); + }); +}); diff --git a/libs/search-docs/state/src/reducers/reducers.ts b/libs/search-docs/state/src/reducers/reducers.ts new file mode 100644 index 0000000000..0d3e5eff48 --- /dev/null +++ b/libs/search-docs/state/src/reducers/reducers.ts @@ -0,0 +1,11 @@ +import { ActionReducerMap } from '@ngrx/store'; + +import { daffSearchDocsCollectionReducer } from './collection/public_api'; +import { DaffSearchDocsReducersState } from './reducers.interface'; + +/** + * The reducers for the entire search feature state. + */ +export const daffSearchDocsReducers: ActionReducerMap = { + docsCollection: daffSearchDocsCollectionReducer, +}; diff --git a/libs/search-docs/state/src/reducers/store-feature-key.ts b/libs/search-docs/state/src/reducers/store-feature-key.ts new file mode 100644 index 0000000000..cb04d0eb10 --- /dev/null +++ b/libs/search-docs/state/src/reducers/store-feature-key.ts @@ -0,0 +1,4 @@ +/** + * The key under which the search feature state will be stored. + */ +export const DAFF_SEARCH_DOCS_STORE_FEATURE_KEY = 'daffSearchDocs'; diff --git a/libs/search-docs/state/src/selectors/collection/selectors.ts b/libs/search-docs/state/src/selectors/collection/selectors.ts new file mode 100644 index 0000000000..d4ccbeeb65 --- /dev/null +++ b/libs/search-docs/state/src/selectors/collection/selectors.ts @@ -0,0 +1,24 @@ +import { createSelector } from '@ngrx/store'; + +import { + DaffCollectionMemoizedSelectors, + daffCollectionSelectorFactory, +} from '@daffodil/core/state'; + +import { DaffSearchDocsStateRootSlice } from '../../reducers/public_api'; +import { getDaffSearchDocsReducersStateSelector } from '../search-feature.selector'; + +const { + selectSearchDocsFeatureState, +} = getDaffSearchDocsReducersStateSelector(); + +const selectSearchDocsCollectionState = createSelector( + selectSearchDocsFeatureState, + state => state.docsCollection, +); + +export const getSearchDocsCollectionSelectors = (() => { + let cache: any; + return (): DaffCollectionMemoizedSelectors => + cache = cache || daffCollectionSelectorFactory(selectSearchDocsCollectionState); +})(); diff --git a/libs/search-docs/state/src/selectors/incremental/selectors.ts b/libs/search-docs/state/src/selectors/incremental/selectors.ts new file mode 100644 index 0000000000..f1e72e7c17 --- /dev/null +++ b/libs/search-docs/state/src/selectors/incremental/selectors.ts @@ -0,0 +1,10 @@ +import { defaultMemoize } from '@ngrx/store'; + +import { daffSearchGetIncrementalSelectors } from '@daffodil/search/state'; + +import { + daffSearchDocsCreateSelectors, + DaffSearchDocsSelectors, +} from '../search.selector'; + +export const daffSearchDocsGetIncrementalSelectors: () => DaffSearchDocsSelectors = defaultMemoize(() => daffSearchDocsCreateSelectors(daffSearchGetIncrementalSelectors().selectSearchResultIds)).memoized; diff --git a/libs/search-docs/state/src/selectors/page/selectors.ts b/libs/search-docs/state/src/selectors/page/selectors.ts new file mode 100644 index 0000000000..3e68c767d8 --- /dev/null +++ b/libs/search-docs/state/src/selectors/page/selectors.ts @@ -0,0 +1,10 @@ +import { defaultMemoize } from '@ngrx/store'; + +import { daffSearchGetPageSelectors } from '@daffodil/search/state'; + +import { + daffSearchDocsCreateSelectors, + DaffSearchDocsSelectors, +} from '../search.selector'; + +export const daffSearchDocsGetPageSelectors: () => DaffSearchDocsSelectors = defaultMemoize(() => daffSearchDocsCreateSelectors(daffSearchGetPageSelectors().selectSearchResultIds)).memoized; diff --git a/libs/search-docs/state/src/selectors/public_api.ts b/libs/search-docs/state/src/selectors/public_api.ts new file mode 100644 index 0000000000..edc7fd8238 --- /dev/null +++ b/libs/search-docs/state/src/selectors/public_api.ts @@ -0,0 +1,5 @@ +export * from './collection/selectors'; +export * from './page/selectors'; +export * from './incremental/selectors'; +export * from './search.selector'; +export * from './search-feature.selector'; diff --git a/libs/search-docs/state/src/selectors/search-feature.selector.ts b/libs/search-docs/state/src/selectors/search-feature.selector.ts new file mode 100644 index 0000000000..eb44bf65db --- /dev/null +++ b/libs/search-docs/state/src/selectors/search-feature.selector.ts @@ -0,0 +1,25 @@ +import { + createFeatureSelector, + MemoizedSelector, +} from '@ngrx/store'; + +import { + DaffSearchDocsStateRootSlice, + DaffSearchDocsReducersState, + DAFF_SEARCH_DOCS_STORE_FEATURE_KEY, +} from '../reducers/public_api'; + +/** + * Selector for the search docs feature state. + */ +export interface DaffSearchDocsFeatureSelector { + selectSearchDocsFeatureState: MemoizedSelector; +} + +export const getDaffSearchDocsReducersStateSelector = (() => { + let cache: any; + return (): DaffSearchDocsFeatureSelector => + cache = cache || { + selectSearchDocsFeatureState: createFeatureSelector(DAFF_SEARCH_DOCS_STORE_FEATURE_KEY), + }; +})(); diff --git a/libs/search-docs/state/src/selectors/search.selector.spec.ts b/libs/search-docs/state/src/selectors/search.selector.spec.ts new file mode 100644 index 0000000000..42efd242b5 --- /dev/null +++ b/libs/search-docs/state/src/selectors/search.selector.spec.ts @@ -0,0 +1,113 @@ +import { TestBed } from '@angular/core/testing'; +import { + Store, + StoreModule, + select, + combineReducers, +} from '@ngrx/store'; +import { cold } from 'jasmine-marbles'; + +import { + DaffDocsGridLoadSuccess, + daffDocsReducers, + DAFF_DOCS_STORE_FEATURE_KEY, +} from '@daffodil/docs/state'; +import { DaffDocsTestingModule } from '@daffodil/docs/testing'; +import { daffSearchTransformResultsToCollection } from '@daffodil/search'; +import { + daffSearchGetPageSelectors, + DaffSearchLoadSuccess, + daffSearchReducers, + DAFF_SEARCH_STORE_FEATURE_KEY, +} from '@daffodil/search/state'; +import { DaffSearchDocsResult } from '@daffodil/search-docs'; +import { + DaffSearchDocsStateRootSlice, + DAFF_SEARCH_DOCS_STORE_FEATURE_KEY, + daffSearchDocsReducers, +} from '@daffodil/search-docs/state'; +import { DaffSearchDocsResultFactory } from '@daffodil/search-docs/testing'; + +import { daffSearchDocsCreateSelectors } from './search.selector'; + +describe('@daffodil/search-docs/state | daffSearchDocsCreateSelectors', () => { + let store: Store; + + let searchResultFactory: DaffSearchDocsResultFactory; + + let mockSearchResults: DaffSearchDocsResult[]; + + const { + selectDocsResultIds, + selectDocsResults, + } = daffSearchDocsCreateSelectors(daffSearchGetPageSelectors().selectSearchResultIds); + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ + [DAFF_SEARCH_STORE_FEATURE_KEY]: combineReducers(daffSearchReducers), + [DAFF_DOCS_STORE_FEATURE_KEY]: combineReducers(daffDocsReducers), + [DAFF_SEARCH_DOCS_STORE_FEATURE_KEY]: combineReducers(daffSearchDocsReducers), + }), + DaffDocsTestingModule, + ], + }); + + store = TestBed.inject(Store); + searchResultFactory = TestBed.inject(DaffSearchDocsResultFactory); + + mockSearchResults = searchResultFactory.createMany(); + }); + + describe('selectDocsResultIds', () => { + it('should initially be an empty array', () => { + const selector = store.pipe(select(selectDocsResultIds)); + const expected = cold('a', { a: []}); + + expect(selector).toBeObservable(expected); + }); + + describe('when search results have been loaded', () => { + beforeEach(() => { + store.dispatch(new DaffSearchLoadSuccess({ + collection: daffSearchTransformResultsToCollection(mockSearchResults), + metadata: {}, + })); + }); + + it('should select the docs search result IDs', () => { + const selector = store.pipe(select(selectDocsResultIds)); + const expected = cold('a', { a: mockSearchResults.map(({ id }) => id) }); + + expect(selector).toBeObservable(expected); + }); + }); + }); + + describe('selectDocsResults', () => { + it('should initially be an empty array', () => { + const selector = store.pipe(select(selectDocsResults)); + const expected = cold('a', { a: []}); + + expect(selector).toBeObservable(expected); + }); + + describe('when search results have been loaded', () => { + beforeEach(() => { + store.dispatch(new DaffSearchLoadSuccess({ + collection: daffSearchTransformResultsToCollection(mockSearchResults), + metadata: {}, + })); + store.dispatch(new DaffDocsGridLoadSuccess(mockSearchResults)); + }); + + it('should select the docs search results', () => { + const selector = store.pipe(select(selectDocsResults)); + const expected = cold('a', { a: mockSearchResults }); + + expect(selector).toBeObservable(expected); + }); + }); + }); +}); diff --git a/libs/search-docs/state/src/selectors/search.selector.ts b/libs/search-docs/state/src/selectors/search.selector.ts new file mode 100644 index 0000000000..25db7a092a --- /dev/null +++ b/libs/search-docs/state/src/selectors/search.selector.ts @@ -0,0 +1,63 @@ +import { Dictionary } from '@ngrx/entity'; +import { + createSelector, + MemoizedSelector, +} from '@ngrx/store'; + +import { getDaffDocsSelectors } from '@daffodil/docs/state'; +import { DaffDocsItem } from '@daffodil/docs-utils'; +import { DaffSearchSelectors } from '@daffodil/search/state'; +import { + DaffSearchDocsResult, + DAFF_SEARCH_DOCS_RESULT_KIND, +} from '@daffodil/search-docs'; + +import { DaffSearchDocsStateRootSlice } from '../reducers/public_api'; + +/** + * Selectors for docs results on a search page. + */ +export interface DaffSearchDocsSelectors { + /** + * Select the docs search result IDs from the main search state. + */ + selectDocsResultIds: MemoizedSelector>; + + /** + * Select the docs search results from the main docs state. + */ + selectDocsResults: MemoizedSelector>; +} + +export const daffSearchDocsCreateSelectors = ( + selectSearchResultIds: DaffSearchSelectors['selectSearchResultIds'], +) => { + const { selectDocsEntities } = getDaffDocsSelectors(); + + const selectDocsResultIds = createSelector( + selectSearchResultIds, + state => state[DAFF_SEARCH_DOCS_RESULT_KIND] || [], + ); + + const selectDocsResults = createSelector], Array>( + selectDocsResultIds, + selectDocsEntities, + (resultIds, docsEntities) => resultIds.reduce>((acc, id) => { + const docs = docsEntities[id]; + if (docs) { + acc.push({ + ...docs, + url: docs.path, + kind: 'kind' in docs ? docs.kind : DAFF_SEARCH_DOCS_RESULT_KIND, + }); + } + + return acc; + }, []), + ); + + return { + selectDocsResultIds, + selectDocsResults, + }; +}; diff --git a/libs/search-docs/state/src/state.module.ts b/libs/search-docs/state/src/state.module.ts new file mode 100644 index 0000000000..71bb828865 --- /dev/null +++ b/libs/search-docs/state/src/state.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core'; + +import { DaffSearchDocsStateCoreModule } from './core.module'; + +@NgModule({ + imports: [ + DaffSearchDocsStateCoreModule, + ], +}) +export class DaffSearchDocsStateModule { +} diff --git a/libs/search-docs/state/testing/ng-package.json b/libs/search-docs/state/testing/ng-package.json new file mode 100644 index 0000000000..7dcb29e536 --- /dev/null +++ b/libs/search-docs/state/testing/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-entrypoint.schema.json", + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/search-docs/state/testing/src/index.ts b/libs/search-docs/state/testing/src/index.ts new file mode 100644 index 0000000000..4aaf8f92ed --- /dev/null +++ b/libs/search-docs/state/testing/src/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/libs/search-docs/state/testing/src/mock-search-facade.ts b/libs/search-docs/state/testing/src/mock-search-facade.ts new file mode 100644 index 0000000000..e53240dbff --- /dev/null +++ b/libs/search-docs/state/testing/src/mock-search-facade.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@angular/core'; +import { Action } from '@ngrx/store'; +import { BehaviorSubject } from 'rxjs'; + +import { DaffFilters } from '@daffodil/core'; +import { DaffSearchDocsResult } from '@daffodil/search-docs'; +import { DaffSearchDocsFacadeInterface } from '@daffodil/search-docs/state'; + +/** + * Mocks out facade fields and methods for testing purposes. + * + * @inheritdoc + */ +@Injectable({ providedIn: 'root' }) +export class MockDaffSearchDocsFacade implements DaffSearchDocsFacadeInterface { + filters$ = new BehaviorSubject({}); + appliedFilters$ = new BehaviorSubject({}); + docsResults$ = new BehaviorSubject>([]); + + dispatch(action: Action) {}; +} diff --git a/libs/search-docs/state/testing/src/public_api.ts b/libs/search-docs/state/testing/src/public_api.ts new file mode 100644 index 0000000000..9a55ca323a --- /dev/null +++ b/libs/search-docs/state/testing/src/public_api.ts @@ -0,0 +1,2 @@ +export { MockDaffSearchDocsFacade } from './mock-search-facade'; +export { DaffSearchDocsStateTestingModule } from './testing.module'; diff --git a/libs/search-docs/state/testing/src/testing.module.ts b/libs/search-docs/state/testing/src/testing.module.ts new file mode 100644 index 0000000000..a78f9ba70e --- /dev/null +++ b/libs/search-docs/state/testing/src/testing.module.ts @@ -0,0 +1,22 @@ +import { NgModule } from '@angular/core'; + +import { MockDaffCollectionFacade } from '@daffodil/core/state/testing'; +import { + DaffSearchDocsCollectionFacade, + DaffSearchDocsIncrementalFacade, + DaffSearchDocsPageFacade, +} from '@daffodil/search-docs/state'; + +import { MockDaffSearchDocsFacade } from './mock-search-facade'; + +/** + * Provides the {@link MockDaffSearchDocsFacade} for {@link DaffSearchDocsFacade}. + */ +@NgModule({ + providers: [ + { provide: DaffSearchDocsPageFacade, useExisting: MockDaffSearchDocsFacade }, + { provide: DaffSearchDocsIncrementalFacade, useExisting: MockDaffSearchDocsFacade }, + { provide: DaffSearchDocsCollectionFacade, useExisting: MockDaffCollectionFacade }, + ], +}) +export class DaffSearchDocsStateTestingModule {} diff --git a/libs/search-docs/test.ts b/libs/search-docs/test.ts new file mode 100644 index 0000000000..2f5f660b3d --- /dev/null +++ b/libs/search-docs/test.ts @@ -0,0 +1,18 @@ +// This file is required by karma.conf.js and loads recursively all the .spec and framework files + + +// eslint-disable-next-line import/no-unassigned-import +import 'zone.js'; +// eslint-disable-next-line import/no-unassigned-import +import 'zone.js/testing'; +import { getTestBed } from '@angular/core/testing'; +import { + BrowserDynamicTestingModule, + platformBrowserDynamicTesting, +} from '@angular/platform-browser-dynamic/testing'; + +// First, initialize the Angular testing environment. +getTestBed().initTestEnvironment( + BrowserDynamicTestingModule, + platformBrowserDynamicTesting(), +); diff --git a/libs/search-docs/tsconfig.json b/libs/search-docs/tsconfig.json new file mode 100644 index 0000000000..277a4e531c --- /dev/null +++ b/libs/search-docs/tsconfig.json @@ -0,0 +1,26 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": "../..", + "paths": { + "@daffodil/*": [ + "dist/*" + ], + "@daffodil/search-docs": [ + "libs/search-docs/src" + ], + "@daffodil/search-docs/state": [ + "libs/search-docs/state/src" + ], + "@daffodil/search-docs/state/testing": [ + "libs/search-docs/state/testing/src" + ], + "@daffodil/search-docs/driver": [ + "libs/search-docs/driver/src" + ], + "@daffodil/search-docs/driver/algolia": [ + "libs/search-docs/driver/algolia/src" + ], + } + } +} diff --git a/libs/search-docs/tsconfig.lib.json b/libs/search-docs/tsconfig.lib.json new file mode 100644 index 0000000000..e4939f495f --- /dev/null +++ b/libs/search-docs/tsconfig.lib.json @@ -0,0 +1,27 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc/libs/search-docs", + "declarationMap": true, + "declaration": true, + "sourceMap": true, + "inlineSources": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "importHelpers": true, + "types": [], + }, + "angularCompilerOptions": { + "annotateForClosureCompiler": true, + "skipTemplateCodegen": true, + "strictMetadataEmit": true, + "fullTemplateTypeCheck": true, + "strictInjectionParameters": true, + "flatModuleId": "AUTOGENERATED", + "flatModuleOutFile": "AUTOGENERATED" + }, + "exclude": [ + "test.ts", + "**/*.spec.ts" + ] +} diff --git a/libs/search-docs/tsconfig.lib.prod.json b/libs/search-docs/tsconfig.lib.prod.json new file mode 100644 index 0000000000..2a2faa884c --- /dev/null +++ b/libs/search-docs/tsconfig.lib.prod.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/libs/search-docs/tsconfig.spec.json b/libs/search-docs/tsconfig.spec.json new file mode 100644 index 0000000000..46095afee2 --- /dev/null +++ b/libs/search-docs/tsconfig.spec.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc/libs/search-docs", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "types": [ + "jasmine", + "node" + ] + }, + "files": [ + "test.ts" + ], + "include": [ + "**/*.spec.ts", + "**/*.d.ts" + ] +} diff --git a/libs/search/driver/algolia/README.md b/libs/search/driver/algolia/README.md new file mode 100644 index 0000000000..c4a49b81e1 --- /dev/null +++ b/libs/search/driver/algolia/README.md @@ -0,0 +1,2 @@ +# @daffodil/search/driver/algolia + diff --git a/libs/search/driver/algolia/ng-package.json b/libs/search/driver/algolia/ng-package.json new file mode 100644 index 0000000000..7dcb29e536 --- /dev/null +++ b/libs/search/driver/algolia/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/ng-entrypoint.schema.json", + "lib": { + "entryFile": "src/index.ts" + } +} diff --git a/libs/search/driver/algolia/src/algolia.provider.ts b/libs/search/driver/algolia/src/algolia.provider.ts new file mode 100644 index 0000000000..44f9db6fc0 --- /dev/null +++ b/libs/search/driver/algolia/src/algolia.provider.ts @@ -0,0 +1,14 @@ +import { makeEnvironmentProviders } from '@angular/core'; + +import { provideDaffSearchDriver } from '@daffodil/search/driver'; + +import { provideDaffSearchAlgoliaConfig } from './config/public_api'; +import { DaffSearchAlgoliaDriver } from './search.service'; +import { AlgoliaSearchCollectionTransformer } from './transformers/collection.service'; + +export const provideDaffAlgoliaSearchDriver = (config: Parameters[0]) => + makeEnvironmentProviders([ + AlgoliaSearchCollectionTransformer, + provideDaffSearchDriver(DaffSearchAlgoliaDriver), + provideDaffSearchAlgoliaConfig(config), + ]); diff --git a/libs/search/driver/algolia/src/config/default.ts b/libs/search/driver/algolia/src/config/default.ts new file mode 100644 index 0000000000..be7c6295ca --- /dev/null +++ b/libs/search/driver/algolia/src/config/default.ts @@ -0,0 +1,4 @@ +/** + * The default configuration for the {@link DaffSearchAlgoliaDriverConfig}. + */ +export const DAFF_SEARCH_ALGOLIA_CONFIG_DEFAULT = {}; diff --git a/libs/search/driver/algolia/src/config/public_api.ts b/libs/search/driver/algolia/src/config/public_api.ts new file mode 100644 index 0000000000..c01e92e84b --- /dev/null +++ b/libs/search/driver/algolia/src/config/public_api.ts @@ -0,0 +1,3 @@ +export { DAFF_SEARCH_ALGOLIA_CONFIG_DEFAULT } from './default'; +export * from './type'; +export * from './token'; diff --git a/libs/search/driver/algolia/src/config/token.ts b/libs/search/driver/algolia/src/config/token.ts new file mode 100644 index 0000000000..01821d8609 --- /dev/null +++ b/libs/search/driver/algolia/src/config/token.ts @@ -0,0 +1,19 @@ +import { createConfigInjectionToken } from '@daffodil/core'; + +import { DAFF_SEARCH_ALGOLIA_CONFIG_DEFAULT } from './default'; +import { DaffSearchAlgoliaDriverConfig } from './type'; + +export const { + /** + * The token used to provide @daffodil/search/driver/algolia config data. + * Mandatory for the Magento driver. + */ + token: DAFF_SEARCH_ALGOLIA_CONFIG_TOKEN, + /** + * Provider function for {@link DAFF_SEARCH_ALGOLIA_CONFIG_TOKEN}. + */ + provider: provideDaffSearchAlgoliaConfig, +} = createConfigInjectionToken( + DAFF_SEARCH_ALGOLIA_CONFIG_DEFAULT, + 'DAFF_SEARCH_ALGOLIA_CONFIG_TOKEN', +); diff --git a/libs/search/driver/algolia/src/config/type.ts b/libs/search/driver/algolia/src/config/type.ts new file mode 100644 index 0000000000..152947ef49 --- /dev/null +++ b/libs/search/driver/algolia/src/config/type.ts @@ -0,0 +1,17 @@ +/** + * An interface for providing `@daffodil/search/driver/algolia` with necessary config values. + */ +export interface DaffSearchAlgoliaDriverConfig { + /** + * The Algolia application ID. + */ + appId: string; + /** + * The Algolia API key. + */ + apiKey: string; + /** + * The name of the Algolia index to search. + */ + indexName: string; +} diff --git a/libs/search/driver/algolia/src/index.ts b/libs/search/driver/algolia/src/index.ts new file mode 100644 index 0000000000..4aaf8f92ed --- /dev/null +++ b/libs/search/driver/algolia/src/index.ts @@ -0,0 +1 @@ +export * from './public_api'; diff --git a/libs/search/driver/algolia/src/public_api.ts b/libs/search/driver/algolia/src/public_api.ts new file mode 100644 index 0000000000..a6651fe743 --- /dev/null +++ b/libs/search/driver/algolia/src/public_api.ts @@ -0,0 +1,7 @@ +export * from './result/get-kind/public_api'; +export * from './result/transform/public_api'; +export * from './config/public_api'; +export * from './algolia.provider'; + +export { DaffSearchAlgoliaDriver } from './search.service'; +export { DaffSearchAlgoliaDriverModule } from './search-driver.module'; diff --git a/libs/search/driver/algolia/src/result/get-kind/public_api.ts b/libs/search/driver/algolia/src/result/get-kind/public_api.ts new file mode 100644 index 0000000000..452c6b52f9 --- /dev/null +++ b/libs/search/driver/algolia/src/result/get-kind/public_api.ts @@ -0,0 +1,2 @@ +export * from './token'; +export * from './type'; diff --git a/libs/search/driver/algolia/src/result/get-kind/token.ts b/libs/search/driver/algolia/src/result/get-kind/token.ts new file mode 100644 index 0000000000..8fc6de1f23 --- /dev/null +++ b/libs/search/driver/algolia/src/result/get-kind/token.ts @@ -0,0 +1,8 @@ +import { createSingleInjectionToken } from '@daffodil/core'; + +import { AlgoliaSearchResultGetKind } from './type'; + +export const { + token: ALGOLIA_SEARCH_RESULT_GET_KIND, + provider: provideAlgoliaSearchResultGetKind, +} = createSingleInjectionToken('ALGOLIA_SEARCH_RESULT_GET_KIND'); diff --git a/libs/search/driver/algolia/src/result/get-kind/type.ts b/libs/search/driver/algolia/src/result/get-kind/type.ts new file mode 100644 index 0000000000..3a84117581 --- /dev/null +++ b/libs/search/driver/algolia/src/result/get-kind/type.ts @@ -0,0 +1,3 @@ +import { Hit } from '@algolia/client-search'; + +export type AlgoliaSearchResultGetKind = (hit: Hit) => string; diff --git a/libs/search/driver/algolia/src/result/transform/public_api.ts b/libs/search/driver/algolia/src/result/transform/public_api.ts new file mode 100644 index 0000000000..452c6b52f9 --- /dev/null +++ b/libs/search/driver/algolia/src/result/transform/public_api.ts @@ -0,0 +1,2 @@ +export * from './token'; +export * from './type'; diff --git a/libs/search/driver/algolia/src/result/transform/token.ts b/libs/search/driver/algolia/src/result/transform/token.ts new file mode 100644 index 0000000000..c3713b3311 --- /dev/null +++ b/libs/search/driver/algolia/src/result/transform/token.ts @@ -0,0 +1,8 @@ +import { createMultiInjectionToken } from '@daffodil/core'; + +import { AlgoliaSearchResultTransformInjection } from './type'; + +export const { + token: ALGOLIA_SEARCH_RESULT_TRANSFORMS, + provider: provideAlgoliaSearchResultTransforms, +} = createMultiInjectionToken('ALGOLIA_SEARCH_RESULT_TRANSFORMS'); diff --git a/libs/search/driver/algolia/src/result/transform/transforms.type.ts b/libs/search/driver/algolia/src/result/transform/transforms.type.ts new file mode 100644 index 0000000000..00bd5f9443 --- /dev/null +++ b/libs/search/driver/algolia/src/result/transform/transforms.type.ts @@ -0,0 +1,14 @@ +import { + inject, + InjectionToken, +} from '@angular/core'; + +import { ALGOLIA_SEARCH_RESULT_TRANSFORMS } from './token'; +import { AlgoliaSearchResultTransform } from './type'; + +export const ALGOLIA_SEARCH_RESULT_TRANSFORM_MAP = new InjectionToken('ALGOLIA_SEARCH_RESULT_TRANSFORM', { + factory: () => inject(ALGOLIA_SEARCH_RESULT_TRANSFORMS).reduce((acc, { kind, transform }) => { + acc[kind] = transform; + return acc; + }, >{}), +}); diff --git a/libs/search/driver/algolia/src/result/transform/type.ts b/libs/search/driver/algolia/src/result/transform/type.ts new file mode 100644 index 0000000000..e99f8918a6 --- /dev/null +++ b/libs/search/driver/algolia/src/result/transform/type.ts @@ -0,0 +1,10 @@ +import { Hit } from '@algolia/client-search'; + +import { DaffSearchResult } from '@daffodil/search'; + +export type AlgoliaSearchResultTransform = (hit: Hit) => DaffSearchResult & T; + +export interface AlgoliaSearchResultTransformInjection { + kind: string; + transform: AlgoliaSearchResultTransform; +} diff --git a/libs/search/driver/algolia/src/search-driver.module.ts b/libs/search/driver/algolia/src/search-driver.module.ts new file mode 100644 index 0000000000..303c8ee8f9 --- /dev/null +++ b/libs/search/driver/algolia/src/search-driver.module.ts @@ -0,0 +1,50 @@ +import { CommonModule } from '@angular/common'; +import { + NgModule, + ModuleWithProviders, +} from '@angular/core'; + +import { + DaffSearchDriver, + provideDaffSearchDriver, +} from '@daffodil/search/driver'; + +import { provideDaffSearchAlgoliaConfig } from './config/public_api'; +import { DaffSearchAlgoliaDriver } from './search.service'; +import { AlgoliaSearchCollectionTransformer } from './transformers/collection.service'; + +/** + * Provides the {@link DaffSearchAlgoliaDriver} as the {@link DaffSearchDriver}. + */ +@NgModule({ + imports: [ + CommonModule, + ], + providers: [ + AlgoliaSearchCollectionTransformer, + ], +}) +export class DaffSearchAlgoliaDriverModule { + static forRoot(config: Parameters[0]): ModuleWithProviders { + return { + ngModule: DaffSearchAlgoliaDriverModule, + providers: [ + provideDaffSearchDriver(DaffSearchAlgoliaDriver), + provideDaffSearchAlgoliaConfig(config), + ], + }; + } + + static forFeature(config: Parameters[0]): ModuleWithProviders { + return { + ngModule: DaffSearchAlgoliaDriverModule, + providers: [ + { + provide: DaffSearchDriver, + useClass: DaffSearchAlgoliaDriver, + }, + provideDaffSearchAlgoliaConfig(config), + ], + }; + } +} diff --git a/libs/search/driver/algolia/src/search.service.spec.ts b/libs/search/driver/algolia/src/search.service.spec.ts new file mode 100644 index 0000000000..8c923a4549 --- /dev/null +++ b/libs/search/driver/algolia/src/search.service.spec.ts @@ -0,0 +1,115 @@ +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { cold } from 'jasmine-marbles'; +import { + Observable, + of, +} from 'rxjs'; + +import { DaffSearchResultCollection } from '@daffodil/search'; +import { + DaffSearchDriverKindedInterface, + DaffSearchDriverResponse, +} from '@daffodil/search/driver'; +import { provideDaffSearchAlgoliaDrivers } from '@daffodil/search/driver/algolia'; + +import { DaffSearchAlgoliaDriver } from './search.service'; + +@Injectable({ + providedIn: 'root', +}) +class TestDriver1 implements DaffSearchDriverKindedInterface { + kind = 'TestDriver1'; + + search(query: string) { + return of({ + collection: { + testDriver1: [], + }, + metadata: {}, + }); + } + + incremental(query: string) { + return of({ + testIncremental1: [], + }); + } +} + +@Injectable({ + providedIn: 'root', +}) +class TestDriver2 implements DaffSearchDriverKindedInterface { + kind = 'TestDriver2'; + + search(query: string) { + return of({ + collection: { + testDriver2: [], + }, + metadata: {}, + }); + } + + incremental(query: string) { + return of({ + testIncremental2: [], + }); + } +} + +describe('@daffodil/search/driver/algolia | DaffSearchAlgoliaDriver', () => { + let service: DaffSearchAlgoliaDriver; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + DaffSearchAlgoliaDriver, + ...provideDaffSearchAlgoliaDrivers(TestDriver1, TestDriver2), + ], + }); + + service = TestBed.inject(DaffSearchAlgoliaDriver); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('search | invoking injected drivers', () => { + let result: Observable; + + beforeEach(() => { + result = service.search('query'); + }); + + it('should invoke and collect the result from the injected drivers', () => { + const expected = cold('(a|)', { a: jasmine.objectContaining({ + collection: { + testDriver1: jasmine.truthy(), + testDriver2: jasmine.truthy(), + }, + }) }); + + expect(result).toBeObservable(expected); + }); + }); + + describe('incremental | invoking injected drivers', () => { + let result: Observable; + + beforeEach(() => { + result = service.incremental('query'); + }); + + it('should invoke and collect the result from the injected drivers', () => { + const expected = cold('(a|)', { a: jasmine.objectContaining({ + testIncremental1: jasmine.truthy(), + testIncremental2: jasmine.truthy(), + }) }); + + expect(result).toBeObservable(expected); + }); + }); +}); diff --git a/libs/search/driver/algolia/src/search.service.ts b/libs/search/driver/algolia/src/search.service.ts new file mode 100644 index 0000000000..1d358f7dc1 --- /dev/null +++ b/libs/search/driver/algolia/src/search.service.ts @@ -0,0 +1,68 @@ +import { searchClient } from '@algolia/client-search'; +import { + Inject, + Injectable, +} from '@angular/core'; +import { + from, + Observable, +} from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { DaffSearchResultCollection } from '@daffodil/search'; +import { + DaffSearchDriverInterface, + DaffSearchDriverOptions, + DaffSearchDriverResponse, +} from '@daffodil/search/driver'; + +import { + DaffSearchAlgoliaDriverConfig, + DAFF_SEARCH_ALGOLIA_CONFIG_TOKEN, +} from './config/public_api'; +import { AlgoliaSearchCollectionTransformer } from './transformers/collection.service'; +import { algoliaSearchCollectionTransform } from './transforms/metadata'; + +/** + * A service for searching an Algolia index. + * + * @inheritdoc + */ +@Injectable({ + providedIn: 'root', +}) +export class DaffSearchAlgoliaDriver implements DaffSearchDriverInterface { + private _client = searchClient(this.config.appId, this.config.apiKey); + + constructor( + @Inject(DAFF_SEARCH_ALGOLIA_CONFIG_TOKEN) private config: DaffSearchAlgoliaDriverConfig, + private collectionTransformer: AlgoliaSearchCollectionTransformer, + ) {} + + search(query: string, options: DaffSearchDriverOptions = {}): Observable { + return from(this._client.searchSingleIndex({ + indexName: this.config.indexName, + searchParams: { + query, + length: options.limit, + }, + })).pipe( + map(response => ({ + collection: this.collectionTransformer.transform(response), + metadata: algoliaSearchCollectionTransform(response), + })), + ); + } + + incremental(query: string, options: DaffSearchDriverOptions = {}): Observable { + return from(this._client.searchSingleIndex({ + indexName: this.config.indexName, + searchParams: { + query, + length: options.limit, + }, + })).pipe( + map(response => this.collectionTransformer.transform(response)), + ); + } +} diff --git a/libs/search/driver/algolia/src/transformers/collection.service.ts b/libs/search/driver/algolia/src/transformers/collection.service.ts new file mode 100644 index 0000000000..8d2a8e8a7f --- /dev/null +++ b/libs/search/driver/algolia/src/transformers/collection.service.ts @@ -0,0 +1,33 @@ +import { SearchResponse } from '@algolia/client-search'; +import { + Inject, + Injectable, +} from '@angular/core'; + +import { DaffSearchDriverResponse } from '@daffodil/search/driver'; + +import { + ALGOLIA_SEARCH_RESULT_GET_KIND, + AlgoliaSearchResultGetKind, +} from '../result/get-kind/public_api'; +import { AlgoliaSearchResultTransform } from '../result/transform/public_api'; +import { ALGOLIA_SEARCH_RESULT_TRANSFORM_MAP } from '../result/transform/transforms.type'; + +@Injectable() +export class AlgoliaSearchCollectionTransformer { + constructor( + @Inject(ALGOLIA_SEARCH_RESULT_GET_KIND) private getKind: AlgoliaSearchResultGetKind, + @Inject(ALGOLIA_SEARCH_RESULT_TRANSFORM_MAP) private transformMap: Record, + ) {} + + transform(response: SearchResponse): DaffSearchDriverResponse['collection'] { + return response.hits.reduce((acc, val) => { + const kind = this.getKind(val); + if (!acc[kind]) { + acc[kind] = []; + } + acc[kind].push(this.transformMap[kind](val)); + return acc; + }, {}); + } +} diff --git a/libs/search/driver/algolia/src/transforms/metadata.ts b/libs/search/driver/algolia/src/transforms/metadata.ts new file mode 100644 index 0000000000..8aed9c7a07 --- /dev/null +++ b/libs/search/driver/algolia/src/transforms/metadata.ts @@ -0,0 +1,13 @@ +import { SearchResponse } from '@algolia/client-search'; + +import { + DaffCountable, + DaffNumericallyPaginable, +} from '@daffodil/core'; + +export const algoliaSearchCollectionTransform = (response: SearchResponse): DaffNumericallyPaginable & DaffCountable => ({ + currentPage: response.page, + totalPages: response.nbPages, + pageSize: response.hitsPerPage, + count: response.nbHits, +}); diff --git a/libs/search/driver/federated/ng-package.json b/libs/search/driver/federated/ng-package.json index b761d7b2e2..7dcb29e536 100644 --- a/libs/search/driver/federated/ng-package.json +++ b/libs/search/driver/federated/ng-package.json @@ -1,5 +1,5 @@ { - "$schema": "../../../../../node_modules/ng-packagr/ng-entrypoint.schema.json", + "$schema": "../../../../node_modules/ng-packagr/ng-entrypoint.schema.json", "lib": { "entryFile": "src/index.ts" } diff --git a/libs/search/driver/src/interfaces/search-service.interface.ts b/libs/search/driver/src/interfaces/search-service.interface.ts index ec2643327a..3ebedc2b6e 100644 --- a/libs/search/driver/src/interfaces/search-service.interface.ts +++ b/libs/search/driver/src/interfaces/search-service.interface.ts @@ -1,6 +1,9 @@ import { Observable } from 'rxjs'; -import { createSingletonInjectionToken } from '@daffodil/core'; +import { + createSingletonInjectionToken, + DaffCollectionRequest, +} from '@daffodil/core'; import { DaffSearchResult, DaffSearchResultCollection, @@ -22,7 +25,7 @@ export const { /** * The options for making a search. */ -export interface DaffSearchDriverOptions { +export interface DaffSearchDriverOptions extends DaffCollectionRequest { /** * The number of results to request from the platform. * diff --git a/libs/search/tsconfig.json b/libs/search/tsconfig.json index 97b44f318b..766b49db5e 100644 --- a/libs/search/tsconfig.json +++ b/libs/search/tsconfig.json @@ -33,6 +33,9 @@ "@daffodil/search/driver/federated": [ "libs/search/driver/federated/src" ], + "@daffodil/search/driver/algolia": [ + "libs/search/driver/algolia/src" + ], } } } diff --git a/package-lock.json b/package-lock.json index d5db0d9327..ae3a2c53c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.91.0", "license": "MIT", "dependencies": { + "@algolia/client-search": "^5.35.0", "@angular/animations": "^20.0.0", "@angular/cdk": "^20.0.0", "@angular/common": "^20.0.0", @@ -202,7 +203,6 @@ "version": "5.35.0", "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.35.0.tgz", "integrity": "sha512-ipE0IuvHu/bg7TjT2s+187kz/E3h5ssfTtjpg1LbWMgxlgiaZIgTTbyynM7NfpSJSKsgQvCQxWjGUO51WSCu7w==", - "devOptional": true, "license": "MIT", "engines": { "node": ">= 14.0.0" @@ -260,7 +260,6 @@ "version": "5.35.0", "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.35.0.tgz", "integrity": "sha512-FfmdHTrXhIduWyyuko1YTcGLuicVbhUyRjO3HbXE4aP655yKZgdTIfMhZ/V5VY9bHuxv/fGEh3Od1Lvv2ODNTg==", - "devOptional": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.35.0", @@ -324,7 +323,6 @@ "version": "5.35.0", "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.35.0.tgz", "integrity": "sha512-diY415KLJZ6x1Kbwl9u96Jsz0OstE3asjXtJ9pmk1d+5gPuQ5jQyEsgC+WmEXzlec3iuVszm8AzNYYaqw6B+Zw==", - "devOptional": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.35.0" @@ -337,7 +335,6 @@ "version": "5.35.0", "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.35.0.tgz", "integrity": "sha512-uydqnSmpAjrgo8bqhE9N1wgcB98psTRRQXcjc4izwMB7yRl9C8uuAQ/5YqRj04U0mMQ+fdu2fcNF6m9+Z1BzDQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.35.0" @@ -350,7 +347,6 @@ "version": "5.35.0", "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.35.0.tgz", "integrity": "sha512-RgLX78ojYOrThJHrIiPzT4HW3yfQa0D7K+MQ81rhxqaNyNBu4F1r+72LNHYH/Z+y9I1Mrjrd/c/Ue5zfDgAEjQ==", - "devOptional": true, "license": "MIT", "dependencies": { "@algolia/client-common": "5.35.0" diff --git a/package.json b/package.json index 5ef6bec98b..2b8388f31f 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "private": true, "dependencies": { + "@algolia/client-search": "^5.35.0", "@angular/animations": "^20.0.0", "@angular/cdk": "^20.0.0", "@angular/common": "^20.0.0",