diff --git a/projects/www/src/app/components/docs/code-example.component.ts b/projects/www/src/app/components/docs/code-example.component.ts index 9d0f097b06..b07d3c01f5 100644 --- a/projects/www/src/app/components/docs/code-example.component.ts +++ b/projects/www/src/app/components/docs/code-example.component.ts @@ -1,12 +1,20 @@ -import { Component, Input } from '@angular/core'; +import { Component, inject, Input, PLATFORM_ID, signal } from '@angular/core'; +import { isPlatformServer } from '@angular/common'; +import { CodeHighlightPipe } from './code-highlight.pipe'; +import { ExamplesService } from '@ngrx-io/app/examples/examples.service'; @Component({ selector: 'ngrx-code-example', standalone: true, + imports: [CodeHighlightPipe], template: `
{{ header }}
- + @if(path) { +
+ }@else{ + + }
`, styles: [ @@ -35,4 +43,22 @@ import { Component, Input } from '@angular/core'; }) export class CodeExampleComponent { @Input() header: string = ''; + @Input() path: string = ''; + @Input() region: string = ''; + @Input() language: string = 'typescript'; + + private exampleService = inject(ExamplesService); + private platformId = inject(PLATFORM_ID); + protected codeContent = signal(''); + + async ngAfterViewInit(): Promise { + if (isPlatformServer(this.platformId)) return; + if (!this.path) return; + + const content = await this.exampleService.extractSnippet( + this.path, + this.region + ); + this.codeContent.set(content); + } } diff --git a/projects/www/src/app/components/docs/code-highlight.pipe.ts b/projects/www/src/app/components/docs/code-highlight.pipe.ts index d7a599d707..a9b7e6670b 100644 --- a/projects/www/src/app/components/docs/code-highlight.pipe.ts +++ b/projects/www/src/app/components/docs/code-highlight.pipe.ts @@ -1,16 +1,33 @@ -import { Pipe, PipeTransform } from '@angular/core'; -import hljs from 'highlight.js/lib/core'; -import typescript from 'highlight.js/lib/languages/typescript'; +import { inject, Pipe, PipeTransform } from '@angular/core'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; +import { ngrxTheme } from '@ngrx-io/shared/ngrx-shiki-theme'; +import { + BundledLanguage, + BundledTheme, + getHighlighter, + HighlighterGeneric, +} from 'shiki'; -hljs.registerLanguage('typescript', typescript); +let highlighter: HighlighterGeneric; +getHighlighter({ + langs: ['typescript'], + themes: [ngrxTheme], +}).then((h) => (highlighter = h)); @Pipe({ name: 'ngrxCodeHighlight', - pure: true, standalone: true, + pure: true, }) export class CodeHighlightPipe implements PipeTransform { - transform(code: string): string { - return hljs.highlight(code, { language: 'typescript' }).value; + private sanitizer = inject(DomSanitizer); + + transform(code: string): SafeHtml { + const html = highlighter?.codeToHtml(code, { + lang: 'typescript', + theme: 'ngrx-theme', + }); + + return this.sanitizer.bypassSecurityTrustHtml(html); } } diff --git a/projects/www/src/app/examples/component-store-paginator-service/src/app/app.component.ts b/projects/www/src/app/examples/component-store-paginator-service/src/app/app.component.ts new file mode 100644 index 0000000000..6559ff1e5e --- /dev/null +++ b/projects/www/src/app/examples/component-store-paginator-service/src/app/app.component.ts @@ -0,0 +1,19 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-root', + template: ` + + + `, +}) +export class AppComponent { + log(obj: unknown) { + console.log(obj); + } +} diff --git a/projects/www/src/app/examples/component-store-paginator-service/src/app/app.module.ts b/projects/www/src/app/examples/component-store-paginator-service/src/app/app.module.ts new file mode 100644 index 0000000000..9637f709b0 --- /dev/null +++ b/projects/www/src/app/examples/component-store-paginator-service/src/app/app.module.ts @@ -0,0 +1,24 @@ +import { NgModule } from '@angular/core'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatNativeDateModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTooltipModule } from '@angular/material/tooltip'; + +import { AppComponent } from './app.component'; +import { PaginatorComponent } from './paginator.component'; + +@NgModule({ + imports: [ + BrowserAnimationsModule, + MatNativeDateModule, + ReactiveFormsModule, + MatFormFieldModule, + MatSelectModule, + MatTooltipModule, + ], + declarations: [AppComponent, PaginatorComponent], + bootstrap: [AppComponent], +}) +export class AppModule {} diff --git a/projects/www/src/app/examples/component-store-paginator-service/src/app/paginator.component.ts b/projects/www/src/app/examples/component-store-paginator-service/src/app/paginator.component.ts new file mode 100644 index 0000000000..a9cc836c73 --- /dev/null +++ b/projects/www/src/app/examples/component-store-paginator-service/src/app/paginator.component.ts @@ -0,0 +1,68 @@ +import { + Component, + Input, + ChangeDetectionStrategy, + Output, + ViewEncapsulation, +} from '@angular/core'; +import { PaginatorStore } from './paginator.store'; + +@Component({ + selector: 'paginator', + templateUrl: 'paginator.html', + host: { + class: 'mat-paginator', + }, + styleUrls: ['./paginator.scss'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [PaginatorStore], +}) +export class PaginatorComponent { + // #docregion inputs + @Input() set pageIndex(value: string | number) { + this.paginatorStore.setPageIndex(value); + } + + @Input() set length(value: string | number) { + this.paginatorStore.setLength(value); + } + + @Input() set pageSize(value: string | number) { + this.paginatorStore.setPageSize(value); + } + + @Input() set pageSizeOptions(value: readonly number[]) { + this.paginatorStore.setPageSizeOptions(value); + } + // #enddocregion inputs + + // #docregion selectors + // Outputing the event directly from the page$ Observable property. + /** Event emitted when the paginator changes the page size or page index. */ + @Output() readonly page = this.paginatorStore.page$; + + // ViewModel for the PaginatorComponent + readonly vm$ = this.paginatorStore.vm$; + // #enddocregion selectors + + constructor(private readonly paginatorStore: PaginatorStore) {} + + // #docregion updating-state + changePageSize(newPageSize: number) { + this.paginatorStore.changePageSize(newPageSize); + } + nextPage() { + this.paginatorStore.nextPage(); + } + firstPage() { + this.paginatorStore.firstPage(); + } + previousPage() { + this.paginatorStore.previousPage(); + } + lastPage() { + this.paginatorStore.lastPage(); + } + // #enddocregion updating-state +} diff --git a/projects/www/src/app/examples/component-store-paginator-service/src/app/paginator.html b/projects/www/src/app/examples/component-store-paginator-service/src/app/paginator.html new file mode 100644 index 0000000000..504b40e936 --- /dev/null +++ b/projects/www/src/app/examples/component-store-paginator-service/src/app/paginator.html @@ -0,0 +1,79 @@ +
+
+
+
+ Items per page +
+ + + + + {{pageSizeOption}} + + + + +
{{vm.pageSize}}
+
+ +
+
+ {{vm.rangeLabel}} +
+ + + + + +
+
+
\ No newline at end of file diff --git a/projects/www/src/app/examples/component-store-paginator-service/src/app/paginator.scss b/projects/www/src/app/examples/component-store-paginator-service/src/app/paginator.scss new file mode 100644 index 0000000000..484f431451 --- /dev/null +++ b/projects/www/src/app/examples/component-store-paginator-service/src/app/paginator.scss @@ -0,0 +1,80 @@ +@import '~@angular/material/prebuilt-themes/indigo-pink.css'; +/* Add application styles & imports to this file! */ + + +$mat-paginator-padding: 0 8px; +$mat-paginator-page-size-margin-right: 8px; + +$mat-paginator-items-per-page-label-margin: 0 4px; +$mat-paginator-selector-margin: 6px 4px 0 4px; +$mat-paginator-selector-trigger-width: 56px; +$mat-paginator-selector-trigger-outline-width: 64px; +$mat-paginator-selector-trigger-fill-width: 64px; + +$mat-paginator-range-label-margin: 0 32px 0 24px; +$mat-paginator-button-icon-size: 28px; + +.mat-paginator { + display: block; +} + +// Note: this wrapper element is only used to get the flexbox vertical centering to work +// with the `min-height` on IE11. It can be removed if we drop support for IE. +.mat-paginator-outer-container { + display: flex; +} + +.mat-paginator-container { + display: flex; + align-items: center; + justify-content: flex-end; + padding: $mat-paginator-padding; + flex-wrap: wrap-reverse; + width: 100%; +} + +.mat-paginator-page-size { + display: flex; + align-items: baseline; + margin-right: $mat-paginator-page-size-margin-right; + + [dir='rtl'] & { + margin-right: 0; + margin-left: $mat-paginator-page-size-margin-right; + } +} + +.mat-paginator-page-size-label { + margin: $mat-paginator-items-per-page-label-margin; +} + +.mat-paginator-page-size-select { + margin: $mat-paginator-selector-margin; + width: $mat-paginator-selector-trigger-width; + + &.mat-form-field-appearance-outline { + width: $mat-paginator-selector-trigger-outline-width; + } + + &.mat-form-field-appearance-fill { + width: $mat-paginator-selector-trigger-fill-width; + } +} + +.mat-paginator-range-label { + margin: $mat-paginator-range-label-margin; +} + +.mat-paginator-range-actions { + display: flex; + align-items: center; +} + +.mat-paginator-icon { + width: $mat-paginator-button-icon-size; + fill: currentColor; + + [dir='rtl'] & { + transform: rotate(180deg); + } +} \ No newline at end of file diff --git a/projects/www/src/app/examples/component-store-paginator-service/src/app/paginator.store.ts b/projects/www/src/app/examples/component-store-paginator-service/src/app/paginator.store.ts new file mode 100644 index 0000000000..7c4dece416 --- /dev/null +++ b/projects/www/src/app/examples/component-store-paginator-service/src/app/paginator.store.ts @@ -0,0 +1,200 @@ +import { Injectable } from '@angular/core'; +import { ComponentStore } from '@ngrx/component-store'; +import { + filter, + tap, + map, + withLatestFrom, + pairwise, + skip, +} from 'rxjs/operators'; +import { Observable } from 'rxjs'; + +export interface PaginatorState { + /** The current page index. */ + pageIndex: number; + /** The current page size */ + pageSize: number; + /** The current total number of items being paged */ + length: number; + /** The set of provided page size options to display to the user. */ + pageSizeOptions: ReadonlySet; +} + +/** + * Change event object that is emitted when the user selects a + * different page size or navigates to another page. + */ +export interface PageEvent + extends Pick { + /** + * Index of the page that was selected previously. + */ + previousPageIndex?: number; +} + +@Injectable() +export class PaginatorStore extends ComponentStore { + constructor() { + // set defaults + super({ + pageIndex: 0, + pageSize: 50, + length: 0, + pageSizeOptions: new Set([50]), + }); + } + // *********** Updaters *********** // + + readonly setPageIndex = this.updater((state, value: string | number) => ({ + ...state, + pageIndex: Number(value) || 0, + })); + + readonly setPageSize = this.updater((state, value: string | number) => ({ + ...state, + pageSize: Number(value) || 0, + })); + + readonly setLength = this.updater((state, value: string | number) => ({ + ...state, + length: Number(value) || 0, + })); + + readonly setPageSizeOptions = this.updater( + (state, value: readonly number[]) => { + // Making sure that the pageSize is included and sorted + const pageSizeOptions = new Set( + [...value, state.pageSize].sort((a, b) => a - b) + ); + return { ...state, pageSizeOptions }; + } + ); + + readonly changePageSize = this.updater((state, newPageSize: number) => { + const startIndex = state.pageIndex * state.pageSize; + return { + ...state, + pageSize: newPageSize, + pageIndex: Math.floor(startIndex / newPageSize), + }; + }); + + // *********** Selectors *********** // + + readonly hasPreviousPage$ = this.select( + ({ pageIndex, pageSize }) => pageIndex >= 1 && pageSize != 0 + ); + + readonly numberOfPages$ = this.select(({ pageSize, length }) => { + if (!pageSize) return 0; + return Math.ceil(length / pageSize); + }); + + readonly hasNextPage$ = this.select( + this.state$, + this.numberOfPages$, + ({ pageIndex, pageSize }, numberOfPages) => { + const maxPageIndex = numberOfPages - 1; + return pageIndex < maxPageIndex && pageSize != 0; + } + ); + + readonly rangeLabel$ = this.select(({ pageIndex, pageSize, length }) => { + if (length === 0 || pageSize === 0) return `0 of ${length}`; + + length = Math.max(length, 0); + const startIndex = pageIndex * pageSize; + + // If the start index exceeds the list length, do not try and fix the end index to the end. + const endIndex = + startIndex < length + ? Math.min(startIndex + pageSize, length) + : startIndex + pageSize; + + return `${startIndex + 1} – ${endIndex} of ${length}`; + }); + + // #docregion selectors + // ViewModel of Paginator component + readonly vm$ = this.select( + this.state$, + this.hasPreviousPage$, + this.hasNextPage$, + this.rangeLabel$, + (state, hasPreviousPage, hasNextPage, rangeLabel) => ({ + pageSize: state.pageSize, + pageSizeOptions: Array.from(state.pageSizeOptions), + pageIndex: state.pageIndex, + hasPreviousPage, + hasNextPage, + rangeLabel, + }) + ); + + private readonly pageIndexChanges$ = this.state$.pipe( + // map instead of select, so that non-distinct value could go through + map((state) => state.pageIndex), + pairwise() + ); + + readonly page$: Observable = this.select( + // first Observable 👇 + this.pageIndexChanges$, + // second Observable 👇 + this.select((state) => [state.pageSize, state.length]), + // Now combining the results from both of these Observables into a PageEvent object + ([previousPageIndex, pageIndex], [pageSize, length]) => ({ + pageIndex, + previousPageIndex, + pageSize, + length, + }), + // debounce, so that we let the state "settle" + { debounce: true } + ).pipe( + // Skip the emission of the initial state values + skip(1) + ); + // #enddocregion selectors + + readonly nextPage = this.effect((trigger$) => { + return trigger$.pipe( + withLatestFrom(this.hasNextPage$), + filter(([, hasNextPage]) => hasNextPage), + tap(() => { + this.setPageIndex(this.get().pageIndex + 1); + }) + ); + }); + + readonly firstPage = this.effect((trigger$) => { + return trigger$.pipe( + withLatestFrom(this.hasPreviousPage$), + filter(([, hasPreviousPage]) => hasPreviousPage), + tap(() => { + this.setPageIndex(0); + }) + ); + }); + + readonly previousPage = this.effect((trigger$) => { + return trigger$.pipe( + withLatestFrom(this.hasPreviousPage$), + filter(([, hasPreviousPage]) => hasPreviousPage), + tap(() => { + this.setPageIndex(this.get().pageIndex - 1); + }) + ); + }); + + readonly lastPage = this.effect((trigger$) => { + return trigger$.pipe( + withLatestFrom(this.hasNextPage$, this.numberOfPages$), + filter(([, hasNextPage]) => hasNextPage), + tap(([, , numberOfPages]) => { + this.setPageIndex(numberOfPages - 1); + }) + ); + }); +} diff --git a/projects/www/src/app/examples/component-store-paginator-service/src/index.html b/projects/www/src/app/examples/component-store-paginator-service/src/index.html new file mode 100644 index 0000000000..e17a125048 --- /dev/null +++ b/projects/www/src/app/examples/component-store-paginator-service/src/index.html @@ -0,0 +1,13 @@ + + + + + Paginator ComponentStore Example + + + + + + + + diff --git a/projects/www/src/app/examples/component-store-paginator-service/src/main.ts b/projects/www/src/app/examples/component-store-paginator-service/src/main.ts new file mode 100644 index 0000000000..2e578fde01 --- /dev/null +++ b/projects/www/src/app/examples/component-store-paginator-service/src/main.ts @@ -0,0 +1,19 @@ +import './polyfills'; + +import { enableProdMode } from '@angular/core'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { AppModule } from './app/app.module'; + +platformBrowserDynamic() + .bootstrapModule(AppModule) + .then((ref) => { + // Ensure Angular destroys itself on hot reloads. + if (window['ngRef']) { + window['ngRef'].destroy(); + } + window['ngRef'] = ref; + + // Otherwise, log the boot error + }) + .catch((err) => console.error(err)); diff --git a/projects/www/src/app/examples/component-store-paginator-service/src/styles.scss b/projects/www/src/app/examples/component-store-paginator-service/src/styles.scss new file mode 100644 index 0000000000..484f431451 --- /dev/null +++ b/projects/www/src/app/examples/component-store-paginator-service/src/styles.scss @@ -0,0 +1,80 @@ +@import '~@angular/material/prebuilt-themes/indigo-pink.css'; +/* Add application styles & imports to this file! */ + + +$mat-paginator-padding: 0 8px; +$mat-paginator-page-size-margin-right: 8px; + +$mat-paginator-items-per-page-label-margin: 0 4px; +$mat-paginator-selector-margin: 6px 4px 0 4px; +$mat-paginator-selector-trigger-width: 56px; +$mat-paginator-selector-trigger-outline-width: 64px; +$mat-paginator-selector-trigger-fill-width: 64px; + +$mat-paginator-range-label-margin: 0 32px 0 24px; +$mat-paginator-button-icon-size: 28px; + +.mat-paginator { + display: block; +} + +// Note: this wrapper element is only used to get the flexbox vertical centering to work +// with the `min-height` on IE11. It can be removed if we drop support for IE. +.mat-paginator-outer-container { + display: flex; +} + +.mat-paginator-container { + display: flex; + align-items: center; + justify-content: flex-end; + padding: $mat-paginator-padding; + flex-wrap: wrap-reverse; + width: 100%; +} + +.mat-paginator-page-size { + display: flex; + align-items: baseline; + margin-right: $mat-paginator-page-size-margin-right; + + [dir='rtl'] & { + margin-right: 0; + margin-left: $mat-paginator-page-size-margin-right; + } +} + +.mat-paginator-page-size-label { + margin: $mat-paginator-items-per-page-label-margin; +} + +.mat-paginator-page-size-select { + margin: $mat-paginator-selector-margin; + width: $mat-paginator-selector-trigger-width; + + &.mat-form-field-appearance-outline { + width: $mat-paginator-selector-trigger-outline-width; + } + + &.mat-form-field-appearance-fill { + width: $mat-paginator-selector-trigger-fill-width; + } +} + +.mat-paginator-range-label { + margin: $mat-paginator-range-label-margin; +} + +.mat-paginator-range-actions { + display: flex; + align-items: center; +} + +.mat-paginator-icon { + width: $mat-paginator-button-icon-size; + fill: currentColor; + + [dir='rtl'] & { + transform: rotate(180deg); + } +} \ No newline at end of file diff --git a/projects/www/src/app/examples/component-store-paginator/src/app/app.component.ts b/projects/www/src/app/examples/component-store-paginator/src/app/app.component.ts new file mode 100644 index 0000000000..4c951272cd --- /dev/null +++ b/projects/www/src/app/examples/component-store-paginator/src/app/app.component.ts @@ -0,0 +1,19 @@ +import { Component, VERSION } from '@angular/core'; + +@Component({ + selector: 'app-root', + template: ` + + + `, +}) +export class AppComponent { + log(obj: unknown) { + console.log(obj); + } +} diff --git a/projects/www/src/app/examples/component-store-paginator/src/app/app.module.ts b/projects/www/src/app/examples/component-store-paginator/src/app/app.module.ts new file mode 100644 index 0000000000..9637f709b0 --- /dev/null +++ b/projects/www/src/app/examples/component-store-paginator/src/app/app.module.ts @@ -0,0 +1,24 @@ +import { NgModule } from '@angular/core'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatNativeDateModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTooltipModule } from '@angular/material/tooltip'; + +import { AppComponent } from './app.component'; +import { PaginatorComponent } from './paginator.component'; + +@NgModule({ + imports: [ + BrowserAnimationsModule, + MatNativeDateModule, + ReactiveFormsModule, + MatFormFieldModule, + MatSelectModule, + MatTooltipModule, + ], + declarations: [AppComponent, PaginatorComponent], + bootstrap: [AppComponent], +}) +export class AppModule {} diff --git a/projects/www/src/app/examples/component-store-paginator/src/app/paginator.component.ts b/projects/www/src/app/examples/component-store-paginator/src/app/paginator.component.ts new file mode 100644 index 0000000000..8528922c90 --- /dev/null +++ b/projects/www/src/app/examples/component-store-paginator/src/app/paginator.component.ts @@ -0,0 +1,206 @@ +import { + Component, + Input, + ChangeDetectionStrategy, + Output, + EventEmitter, + ViewEncapsulation, +} from '@angular/core'; +import { ComponentStore } from '@ngrx/component-store'; +import { + filter, + tap, + withLatestFrom, + map, + pairwise, + skip, +} from 'rxjs/operators'; + +export interface PaginatorState { + /** The current page index. */ + pageIndex: number; + /** The current page size */ + pageSize: number; + /** The current total number of items being paged */ + length: number; + /** The set of provided page size options to display to the user. */ + pageSizeOptions: ReadonlySet; +} + +/** + * Change event object that is emitted when the user selects a + * different page size or navigates to another page. + */ +export interface PageEvent + extends Pick { + /** + * Index of the page that was selected previously. + */ + previousPageIndex?: number; +} + +@Component({ + selector: 'paginator', + templateUrl: 'paginator.html', + host: { + class: 'mat-paginator', + }, + styleUrls: ['./paginator.scss'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ComponentStore], +}) +export class PaginatorComponent { + @Input() set pageIndex(value: string | number) { + this.setPageIndex(value); + } + + @Input() set length(value: string | number) { + this.componentStore.setState((state) => ({ + ...state, + length: Number(value) || 0, + })); + } + + @Input() set pageSize(value: string | number) { + this.componentStore.setState((state) => ({ + ...state, + pageSize: Number(value) || 0, + })); + } + + @Input() set pageSizeOptions(value: readonly number[]) { + this.componentStore.setState((state) => { + // Making sure that the pageSize is included and sorted + const pageSizeOptions = new Set( + [...value, state.pageSize].sort((a, b) => a - b) + ); + return { ...state, pageSizeOptions }; + }); + } + + private readonly pageIndexChanges$ = this.componentStore.state$.pipe( + // map instead of select, so that non-distinct value could go through + map((state) => state.pageIndex), + pairwise() + ); + + @Output() readonly page = this.componentStore + .select( + // first Observable 👇 + this.pageIndexChanges$, + // second Observable 👇 + this.componentStore.select((state) => [state.pageSize, state.length]), + // Now combining the results from both of these Observables into a PageEvent object + ([previousPageIndex, pageIndex], [pageSize, length]) => ({ + pageIndex, + previousPageIndex, + pageSize, + length, + }), + // debounce, so that we let the state "settle" before emitting a value + { debounce: true } + ) + .pipe( + // Skip the emission of the initial state values + skip(1) + ); + + // *********** Updaters *********** // + + readonly setPageIndex = this.componentStore.updater( + (state, value: string | number) => ({ + ...state, + pageIndex: Number(value) || 0, + }) + ); + + readonly changePageSize = this.componentStore.updater( + (state, newPageSize: number) => { + const startIndex = state.pageIndex * state.pageSize; + return { + ...state, + pageSize: newPageSize, + pageIndex: Math.floor(startIndex / newPageSize), + }; + } + ); + + // *********** Selectors *********** // + + readonly hasPreviousPage$ = this.componentStore.select( + ({ pageIndex, pageSize }) => pageIndex >= 1 && pageSize != 0 + ); + + readonly numberOfPages$ = this.componentStore.select( + ({ pageSize, length }) => { + if (!pageSize) return 0; + return Math.ceil(length / pageSize); + } + ); + + readonly hasNextPage$ = this.componentStore.select( + this.componentStore.state$, + this.numberOfPages$, + ({ pageIndex, pageSize }, numberOfPages) => { + const maxPageIndex = numberOfPages - 1; + return pageIndex < maxPageIndex && pageSize != 0; + } + ); + + readonly rangeLabel$ = this.componentStore.select( + ({ pageIndex, pageSize, length }) => { + if (length == 0 || pageSize == 0) { + return `0 of ${length}`; + } + length = Math.max(length, 0); + + const startIndex = pageIndex * pageSize; + + // If the start index exceeds the list length, do not try and fix the end index to the end. + const endIndex = + startIndex < length + ? Math.min(startIndex + pageSize, length) + : startIndex + pageSize; + + return `${startIndex + 1} – ${endIndex} of ${length}`; + } + ); + + // ViewModel of Paginator component + readonly vm$ = this.componentStore.select( + this.componentStore.state$, + this.hasPreviousPage$, + this.hasNextPage$, + this.rangeLabel$, + (state, hasPreviousPage, hasNextPage, rangeLabel) => ({ + pageSize: state.pageSize, + pageSizeOptions: Array.from(state.pageSizeOptions), + pageIndex: state.pageIndex, + hasPreviousPage, + hasNextPage, + rangeLabel, + }) + ); + + // *********** Effects *********** // + + readonly lastPage = this.componentStore.effect((trigger$) => { + return trigger$.pipe( + withLatestFrom(this.numberOfPages$), + tap(([, numberOfPages]) => { + this.setPageIndex(numberOfPages - 1); + }) + ); + }); + + constructor(private readonly componentStore: ComponentStore) { + // set defaults + this.componentStore.setState({ + pageIndex: 0, + pageSize: 50, + length: 0, + pageSizeOptions: new Set([50]), + }); + } +} diff --git a/projects/www/src/app/examples/component-store-paginator/src/app/paginator.html b/projects/www/src/app/examples/component-store-paginator/src/app/paginator.html new file mode 100644 index 0000000000..a37432e906 --- /dev/null +++ b/projects/www/src/app/examples/component-store-paginator/src/app/paginator.html @@ -0,0 +1,79 @@ +
+
+
+
+ Items per page +
+ + + + + {{pageSizeOption}} + + + + +
{{vm.pageSize}}
+
+ +
+
+ {{vm.rangeLabel}} +
+ + + + + +
+
+
\ No newline at end of file diff --git a/projects/www/src/app/examples/component-store-paginator/src/app/paginator.scss b/projects/www/src/app/examples/component-store-paginator/src/app/paginator.scss new file mode 100644 index 0000000000..484f431451 --- /dev/null +++ b/projects/www/src/app/examples/component-store-paginator/src/app/paginator.scss @@ -0,0 +1,80 @@ +@import '~@angular/material/prebuilt-themes/indigo-pink.css'; +/* Add application styles & imports to this file! */ + + +$mat-paginator-padding: 0 8px; +$mat-paginator-page-size-margin-right: 8px; + +$mat-paginator-items-per-page-label-margin: 0 4px; +$mat-paginator-selector-margin: 6px 4px 0 4px; +$mat-paginator-selector-trigger-width: 56px; +$mat-paginator-selector-trigger-outline-width: 64px; +$mat-paginator-selector-trigger-fill-width: 64px; + +$mat-paginator-range-label-margin: 0 32px 0 24px; +$mat-paginator-button-icon-size: 28px; + +.mat-paginator { + display: block; +} + +// Note: this wrapper element is only used to get the flexbox vertical centering to work +// with the `min-height` on IE11. It can be removed if we drop support for IE. +.mat-paginator-outer-container { + display: flex; +} + +.mat-paginator-container { + display: flex; + align-items: center; + justify-content: flex-end; + padding: $mat-paginator-padding; + flex-wrap: wrap-reverse; + width: 100%; +} + +.mat-paginator-page-size { + display: flex; + align-items: baseline; + margin-right: $mat-paginator-page-size-margin-right; + + [dir='rtl'] & { + margin-right: 0; + margin-left: $mat-paginator-page-size-margin-right; + } +} + +.mat-paginator-page-size-label { + margin: $mat-paginator-items-per-page-label-margin; +} + +.mat-paginator-page-size-select { + margin: $mat-paginator-selector-margin; + width: $mat-paginator-selector-trigger-width; + + &.mat-form-field-appearance-outline { + width: $mat-paginator-selector-trigger-outline-width; + } + + &.mat-form-field-appearance-fill { + width: $mat-paginator-selector-trigger-fill-width; + } +} + +.mat-paginator-range-label { + margin: $mat-paginator-range-label-margin; +} + +.mat-paginator-range-actions { + display: flex; + align-items: center; +} + +.mat-paginator-icon { + width: $mat-paginator-button-icon-size; + fill: currentColor; + + [dir='rtl'] & { + transform: rotate(180deg); + } +} \ No newline at end of file diff --git a/projects/www/src/app/examples/component-store-paginator/src/index.html b/projects/www/src/app/examples/component-store-paginator/src/index.html new file mode 100644 index 0000000000..e17a125048 --- /dev/null +++ b/projects/www/src/app/examples/component-store-paginator/src/index.html @@ -0,0 +1,13 @@ + + + + + Paginator ComponentStore Example + + + + + + + + diff --git a/projects/www/src/app/examples/component-store-paginator/src/main.ts b/projects/www/src/app/examples/component-store-paginator/src/main.ts new file mode 100644 index 0000000000..2e578fde01 --- /dev/null +++ b/projects/www/src/app/examples/component-store-paginator/src/main.ts @@ -0,0 +1,19 @@ +import './polyfills'; + +import { enableProdMode } from '@angular/core'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { AppModule } from './app/app.module'; + +platformBrowserDynamic() + .bootstrapModule(AppModule) + .then((ref) => { + // Ensure Angular destroys itself on hot reloads. + if (window['ngRef']) { + window['ngRef'].destroy(); + } + window['ngRef'] = ref; + + // Otherwise, log the boot error + }) + .catch((err) => console.error(err)); diff --git a/projects/www/src/app/examples/component-store-paginator/src/styles.scss b/projects/www/src/app/examples/component-store-paginator/src/styles.scss new file mode 100644 index 0000000000..484f431451 --- /dev/null +++ b/projects/www/src/app/examples/component-store-paginator/src/styles.scss @@ -0,0 +1,80 @@ +@import '~@angular/material/prebuilt-themes/indigo-pink.css'; +/* Add application styles & imports to this file! */ + + +$mat-paginator-padding: 0 8px; +$mat-paginator-page-size-margin-right: 8px; + +$mat-paginator-items-per-page-label-margin: 0 4px; +$mat-paginator-selector-margin: 6px 4px 0 4px; +$mat-paginator-selector-trigger-width: 56px; +$mat-paginator-selector-trigger-outline-width: 64px; +$mat-paginator-selector-trigger-fill-width: 64px; + +$mat-paginator-range-label-margin: 0 32px 0 24px; +$mat-paginator-button-icon-size: 28px; + +.mat-paginator { + display: block; +} + +// Note: this wrapper element is only used to get the flexbox vertical centering to work +// with the `min-height` on IE11. It can be removed if we drop support for IE. +.mat-paginator-outer-container { + display: flex; +} + +.mat-paginator-container { + display: flex; + align-items: center; + justify-content: flex-end; + padding: $mat-paginator-padding; + flex-wrap: wrap-reverse; + width: 100%; +} + +.mat-paginator-page-size { + display: flex; + align-items: baseline; + margin-right: $mat-paginator-page-size-margin-right; + + [dir='rtl'] & { + margin-right: 0; + margin-left: $mat-paginator-page-size-margin-right; + } +} + +.mat-paginator-page-size-label { + margin: $mat-paginator-items-per-page-label-margin; +} + +.mat-paginator-page-size-select { + margin: $mat-paginator-selector-margin; + width: $mat-paginator-selector-trigger-width; + + &.mat-form-field-appearance-outline { + width: $mat-paginator-selector-trigger-outline-width; + } + + &.mat-form-field-appearance-fill { + width: $mat-paginator-selector-trigger-fill-width; + } +} + +.mat-paginator-range-label { + margin: $mat-paginator-range-label-margin; +} + +.mat-paginator-range-actions { + display: flex; + align-items: center; +} + +.mat-paginator-icon { + width: $mat-paginator-button-icon-size; + fill: currentColor; + + [dir='rtl'] & { + transform: rotate(180deg); + } +} \ No newline at end of file diff --git a/projects/www/src/app/examples/component-store-slide-toggle/src/app/app.component.css b/projects/www/src/app/examples/component-store-slide-toggle/src/app/app.component.css new file mode 100644 index 0000000000..b7ef084c56 --- /dev/null +++ b/projects/www/src/app/examples/component-store-slide-toggle/src/app/app.component.css @@ -0,0 +1,3 @@ +p { + font-family: Lato; +} \ No newline at end of file diff --git a/projects/www/src/app/examples/component-store-slide-toggle/src/app/app.component.ts b/projects/www/src/app/examples/component-store-slide-toggle/src/app/app.component.ts new file mode 100644 index 0000000000..c85a380267 --- /dev/null +++ b/projects/www/src/app/examples/component-store-slide-toggle/src/app/app.component.ts @@ -0,0 +1,21 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-root', + template: ` + Slide me! +
+ I'm ON initially + `, +}) +export class AppComponent { + logFirst(obj: { checked: boolean }) { + console.log('first toggle:', obj.checked); + } + + logSecond(obj: { checked: boolean }) { + console.log('second toggle:', obj.checked); + } +} diff --git a/projects/www/src/app/examples/component-store-slide-toggle/src/app/app.module.ts b/projects/www/src/app/examples/component-store-slide-toggle/src/app/app.module.ts new file mode 100644 index 0000000000..be698cdd5a --- /dev/null +++ b/projects/www/src/app/examples/component-store-slide-toggle/src/app/app.module.ts @@ -0,0 +1,26 @@ +import { NgModule } from '@angular/core'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatNativeDateModule } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatRippleModule } from '@angular/material/core'; + +import { AppComponent } from './app.component'; +import { SlideToggleComponent } from './slide-toggle.component'; + +@NgModule({ + imports: [ + BrowserAnimationsModule, + MatNativeDateModule, + ReactiveFormsModule, + MatFormFieldModule, + MatSelectModule, + MatTooltipModule, + MatRippleModule, + ], + declarations: [AppComponent, SlideToggleComponent], + bootstrap: [AppComponent], +}) +export class AppModule {} diff --git a/projects/www/src/app/examples/component-store-slide-toggle/src/app/slide-toggle.component.ts b/projects/www/src/app/examples/component-store-slide-toggle/src/app/slide-toggle.component.ts new file mode 100644 index 0000000000..9f9ca3fe6a --- /dev/null +++ b/projects/www/src/app/examples/component-store-slide-toggle/src/app/slide-toggle.component.ts @@ -0,0 +1,91 @@ +import { + Component, + Input, + ChangeDetectionStrategy, + Output, + ViewEncapsulation, +} from '@angular/core'; +import { ComponentStore } from '@ngrx/component-store'; +import { tap } from 'rxjs/operators'; + +// #docregion state +export interface SlideToggleState { + checked: boolean; +} +// #enddocregion state + +/** Change event object emitted by a SlideToggleComponent. */ +export interface MatSlideToggleChange { + /** The source MatSlideToggle of the event. */ + readonly source: SlideToggleComponent; + /** The new `checked` value of the MatSlideToggle. */ + readonly checked: boolean; +} + +// #docregion providers +@Component({ + selector: 'mat-slide-toggle', + templateUrl: 'slide-toggle.html', + // #enddocregion providers + styleUrls: ['./slide-toggle.scss'], + encapsulation: ViewEncapsulation.None, + // #docregion providers + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ComponentStore], +}) +export class SlideToggleComponent { + // #enddocregion providers + // #docregion updater + @Input() set checked(value: boolean) { + this.setChecked(value); + } + // #enddocregion updater + // #docregion selector + // Observable used instead of EventEmitter + @Output() readonly change = this.componentStore.select((state) => ({ + source: this, + checked: state.checked, + })); + // #enddocregion selector + + // #docregion updater + readonly setChecked = this.componentStore.updater( + (state, value: boolean) => ({ ...state, checked: value }) + ); + // #enddocregion updater + + // #docregion selector + // ViewModel for the component + readonly vm$ = this.componentStore.select((state) => ({ + checked: state.checked, + })); + // #enddocregion selector + + // #docregion providers, init + constructor( + private readonly componentStore: ComponentStore + ) { + // #enddocregion providers + // set defaults + this.componentStore.setState({ + checked: false, + }); + } + // #enddocregion init + + // #docregion updater + onChangeEvent = this.componentStore.effect<{ + source: Event; + checked: boolean; + }>((event$) => { + return event$.pipe( + tap<{ source: Event; checked: boolean }>((event) => { + event.source.stopPropagation(); + this.setChecked(!event.checked); + }) + ); + }); + // #enddocregion updater + // #docregion providers +} +// #enddocregion providers diff --git a/projects/www/src/app/examples/component-store-slide-toggle/src/app/slide-toggle.html b/projects/www/src/app/examples/component-store-slide-toggle/src/app/slide-toggle.html new file mode 100644 index 0000000000..e0976f7468 --- /dev/null +++ b/projects/www/src/app/examples/component-store-slide-toggle/src/app/slide-toggle.html @@ -0,0 +1,51 @@ +
+
+ + + +
+
diff --git a/projects/www/src/app/examples/component-store-slide-toggle/src/app/slide-toggle.scss b/projects/www/src/app/examples/component-store-slide-toggle/src/app/slide-toggle.scss new file mode 100644 index 0000000000..2444052cf5 --- /dev/null +++ b/projects/www/src/app/examples/component-store-slide-toggle/src/app/slide-toggle.scss @@ -0,0 +1,112 @@ +@use 'sass:map'; +@use '@material/animation' as mdc-animation; +@use '@material/switch/switch' as mdc-switch; +@use '@material/switch/switch-theme' as mdc-switch-theme; +@use '@material/form-field' as mdc-form-field; +@use '@material/ripple' as mdc-ripple; +@use '@material/theme/css' as mdc-theme-css; +@use '@material/feature-targeting' as mdc-feature-targeting; +@import '~@angular/material/prebuilt-themes/indigo-pink.css'; + +$mdc-base-styles-query: mdc-feature-targeting.without( + mdc-feature-targeting.any(color, typography) +); + +@mixin disable-mdc-fallback-declarations { + $previous-value: mdc-theme-css.$enable-fallback-declarations; + mdc-theme-css.$enable-fallback-declarations: false; + @content; + mdc-theme-css.$enable-fallback-declarations: $previous-value; +} + +@include disable-mdc-fallback-declarations { + @include mdc-form-field.core-styles($query: $mdc-base-styles-query); + @include mdc-switch.static-styles-without-ripple; +} + +@mixin fill { + top: 0; + left: 0; + right: 0; + bottom: 0; + position: absolute; +} + +.mat-mdc-slide-toggle { + display: inline-block; + -webkit-tap-highlight-color: transparent; + + // Remove the native outline since we use the ripple for focus indication. + outline: 0; + + .mdc-switch { + // MDC theme styles also include structural styles so we have to include the theme at least + // once here. The values will be overwritten by our own theme file afterwards. + @include disable-mdc-fallback-declarations { + @include mdc-switch-theme.theme-styles(mdc-switch-theme.$light-theme); + } + } + + // The ripple needs extra specificity so the base ripple styling doesn't override its `position`. + .mat-mdc-slide-toggle-ripple, + #{mdc-switch.$ripple-target}::after { + @include fill(); + border-radius: 50%; + // Disable pointer events for the ripple container so that it doesn't eat the mouse events meant + // for the input. Pointer events can be safely disabled because the ripple trigger element is + // the host element. + pointer-events: none; + // Fixes the ripples not clipping to the border radius on Safari. Uses `:not(:empty)` + // in order to avoid creating extra layers when there aren't any ripples. + &:not(:empty) { + transform: translateZ(0); + } + } + + #{mdc-switch.$ripple-target}::after { + content: ''; + opacity: 0; + } + + .mdc-switch:hover #{mdc-switch.$ripple-target}::after { + opacity: map.get(mdc-ripple.$dark-ink-opacities, hover); + transition: mdc-animation.enter(opacity, 75ms); + } + + // Needs a little more specificity so the :hover styles don't override it. + &.mat-mdc-slide-toggle-focused { + .mdc-switch #{mdc-switch.$ripple-target}::after { + opacity: map.get(mdc-ripple.$dark-ink-opacities, focus); + } + + // For slide-toggles render the focus indicator when we know + // the hidden input is focused (slightly different for each control). + .mat-mdc-focus-indicator::before { + content: ''; + } + } + + // We use an Angular Material ripple rather than an MDC ripple due to size concerns, so we need to + // style it appropriately. + .mat-ripple-element { + opacity: map.get(mdc-ripple.$dark-ink-opacities, press); + } + + // Slide-toggle components have to set `border-radius: 50%` in order to support density scaling + // which will clip a square focus indicator so we have to turn it into a circle. + .mat-mdc-focus-indicator::before { + border-radius: 50%; + } + + &._mat-animation-noopable { + .mdc-switch__handle-track, + .mdc-elevation-overlay, + .mdc-switch__icon, + .mdc-switch__handle::before, + .mdc-switch__handle::after, + .mdc-switch__track::before, + .mdc-switch__track::after { + transition: none; + } + } +} diff --git a/projects/www/src/app/examples/component-store-slide-toggle/src/index.html b/projects/www/src/app/examples/component-store-slide-toggle/src/index.html new file mode 100644 index 0000000000..6be188edde --- /dev/null +++ b/projects/www/src/app/examples/component-store-slide-toggle/src/index.html @@ -0,0 +1,13 @@ + + + + + Slide-Toggle ComponentStore Example + + + + + + + + diff --git a/projects/www/src/app/examples/component-store-slide-toggle/src/main.ts b/projects/www/src/app/examples/component-store-slide-toggle/src/main.ts new file mode 100644 index 0000000000..2e578fde01 --- /dev/null +++ b/projects/www/src/app/examples/component-store-slide-toggle/src/main.ts @@ -0,0 +1,19 @@ +import './polyfills'; + +import { enableProdMode } from '@angular/core'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { AppModule } from './app/app.module'; + +platformBrowserDynamic() + .bootstrapModule(AppModule) + .then((ref) => { + // Ensure Angular destroys itself on hot reloads. + if (window['ngRef']) { + window['ngRef'].destroy(); + } + window['ngRef'] = ref; + + // Otherwise, log the boot error + }) + .catch((err) => console.error(err)); diff --git a/projects/www/src/app/examples/examples.service.ts b/projects/www/src/app/examples/examples.service.ts index d34ea4012b..f1cb45a8e1 100644 --- a/projects/www/src/app/examples/examples.service.ts +++ b/projects/www/src/app/examples/examples.service.ts @@ -51,4 +51,112 @@ export class ExamplesService { } ); } + + async extractSnippet(path: string, region?: string): Promise { + try { + const response = await fetch(`/src/app/examples/${path}?raw`); + const content = await response.text(); + + if (region) { + const regionContent = this.extractRegion(content, region); + return this.normalizeIndentation(regionContent); + } + + return this.normalizeIndentation(content); + } catch (error) { + return `// Error loading code from ${path}: ${ + error instanceof Error ? error.message : 'Unknown error' + }`; + } + } + + private extractRegion(content: string, region: string): string { + if (!content) { + return ''; + } + const lines = content.split('\n'); + const startMarker = `#region ${region}`; + const endMarker = `#endregion`; + + // Also support docregion markers like in Angular docs + const docStartMarker = `// #docregion ${region}`; + const docEndMarker = `// #enddocregion`; + + let startIndex = -1; + let endIndex = -1; + + // Look for region markers + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + // Handle //#region style + if (line.includes(startMarker)) { + startIndex = i + 1; + continue; + } + + // Enhanced handling of // #docregion lines that may declare multiple regions separated by commas + if (line.startsWith('//') && line.includes('#docregion')) { + // Example: // #docregion providers, init + const match = line.match(/\/\/\s*#docregion\s+(.*)$/); + if (match) { + const declared = match[1] + .split(',') + .map((r) => r.trim()) + .filter((r) => r.length > 0); // array of region names + // If no specific regions listed (empty array) and region is empty string, treat as match + if ( + (declared.length === 0 && region === '') || + declared.includes(region) + ) { + startIndex = i + 1; + continue; + } + } + // Fallback to legacy single-region substring check for backward compatibility + if (line.includes(docStartMarker)) { + startIndex = i + 1; + continue; + } + } + + // End markers + if ( + (line.includes(endMarker) || line.includes(docEndMarker)) && + startIndex !== -1 + ) { + endIndex = i; + break; + } + } + + if (startIndex === -1) { + return `// Region '${region}' not found in file`; + } + + if (endIndex === -1) { + endIndex = lines.length; + } + + return lines.slice(startIndex, endIndex).join('\n'); + } + + private normalizeIndentation(code: string): string { + const lines = code.split('\n'); + const firstNonEmpty = lines.find((l) => l.trim().length > 0) ?? ''; + const leadingSpacesMatch = firstNonEmpty.match(/^( +)/); + + if (leadingSpacesMatch) { + const indent = leadingSpacesMatch[1]; + const indentLen = indent.length; + // Normalize indentation: if the first non-empty line starts with spaces, remove that exact number from all lines starting with them + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith(indent)) { + lines[i] = lines[i].slice(indentLen); + } + } + } + + return lines.join('\n').trim(); + } } diff --git a/projects/www/src/app/examples/router-store-selectors/src/app/app.component.css b/projects/www/src/app/examples/router-store-selectors/src/app/app.component.css new file mode 100644 index 0000000000..b7ef084c56 --- /dev/null +++ b/projects/www/src/app/examples/router-store-selectors/src/app/app.component.css @@ -0,0 +1,3 @@ +p { + font-family: Lato; +} \ No newline at end of file diff --git a/projects/www/src/app/examples/router-store-selectors/src/app/app.component.html b/projects/www/src/app/examples/router-store-selectors/src/app/app.component.html new file mode 100644 index 0000000000..6a65a85e9f --- /dev/null +++ b/projects/www/src/app/examples/router-store-selectors/src/app/app.component.html @@ -0,0 +1,7 @@ +
    + +
  1. {{ car.make }} | {{ car.model}} | {{ car.year}} + +
+ + \ No newline at end of file diff --git a/projects/www/src/app/examples/router-store-selectors/src/app/app.component.ts b/projects/www/src/app/examples/router-store-selectors/src/app/app.component.ts new file mode 100644 index 0000000000..cc3a8e7ea4 --- /dev/null +++ b/projects/www/src/app/examples/router-store-selectors/src/app/app.component.ts @@ -0,0 +1,31 @@ +import { Component, OnInit } from '@angular/core'; +import { appInit } from './car/car.actions'; +import { selectCars } from './car/car.selectors'; +import { Store } from '@ngrx/store'; +import { RouterLink, RouterOutlet } from '@angular/router'; +import { AsyncPipe, NgFor, NgIf } from '@angular/common'; + +@Component({ + standalone: true, + selector: 'app-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.css'], + imports: [RouterOutlet, NgIf, NgFor, AsyncPipe, RouterLink], +}) +export class AppComponent implements OnInit { + cars$ = this.store.select(selectCars); + + constructor(private store: Store) {} + + ngOnInit() { + this.store.dispatch( + appInit({ + cars: [ + { id: '1', make: 'ford', model: 'mustang', year: '2005' }, + { id: '2', make: 'ford', model: 'mustang', year: '1987' }, + { id: '3', make: 'ford', model: 'mustang', year: '1976' }, + ], + }) + ); + } +} diff --git a/projects/www/src/app/examples/router-store-selectors/src/app/app.config.ts b/projects/www/src/app/examples/router-store-selectors/src/app/app.config.ts new file mode 100644 index 0000000000..c1d9b52b68 --- /dev/null +++ b/projects/www/src/app/examples/router-store-selectors/src/app/app.config.ts @@ -0,0 +1,32 @@ +import { ApplicationConfig } from '@angular/core'; +import { provideStore } from '@ngrx/store'; +import { provideStoreDevtools } from '@ngrx/store-devtools'; +import { provideRouterStore, routerReducer } from '@ngrx/router-store'; +import { + provideRouter, + withEnabledBlockingInitialNavigation, +} from '@angular/router'; +import { isDevMode } from '@angular/core'; +import { reducer } from './car/car.reducer'; +import { CarComponent } from './car/car.component'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideStore({ cars: reducer, router: routerReducer }), + provideRouter( + [ + { + path: ':carId', + component: CarComponent, + }, + ], + withEnabledBlockingInitialNavigation() + ), + provideStoreDevtools({ + maxAge: 25, + logOnly: !isDevMode(), + name: 'NgRx Standalone App', + }), + provideRouterStore(), + ], +}; diff --git a/projects/www/src/app/examples/router-store-selectors/src/app/car/car.actions.ts b/projects/www/src/app/examples/router-store-selectors/src/app/car/car.actions.ts new file mode 100644 index 0000000000..3d8054ba39 --- /dev/null +++ b/projects/www/src/app/examples/router-store-selectors/src/app/car/car.actions.ts @@ -0,0 +1,5 @@ +import { createAction, props } from '@ngrx/store'; +import { Car } from './car.reducer'; + +// for our example, we'll only populate cars in the store on app init +export const appInit = createAction('[App] Init', props<{ cars: Car[] }>()); diff --git a/projects/www/src/app/examples/router-store-selectors/src/app/car/car.component.css b/projects/www/src/app/examples/router-store-selectors/src/app/car/car.component.css new file mode 100644 index 0000000000..2436c587f4 --- /dev/null +++ b/projects/www/src/app/examples/router-store-selectors/src/app/car/car.component.css @@ -0,0 +1,5 @@ +.container { + border-style: solid; + border-width: 4px; + border-radius: 4px; +} diff --git a/projects/www/src/app/examples/router-store-selectors/src/app/car/car.component.html b/projects/www/src/app/examples/router-store-selectors/src/app/car/car.component.html new file mode 100644 index 0000000000..d1b1ed17a4 --- /dev/null +++ b/projects/www/src/app/examples/router-store-selectors/src/app/car/car.component.html @@ -0,0 +1,5 @@ +
+

Car Component

+ +
{{ car$ | async | json }}
+
diff --git a/projects/www/src/app/examples/router-store-selectors/src/app/car/car.component.ts b/projects/www/src/app/examples/router-store-selectors/src/app/car/car.component.ts new file mode 100644 index 0000000000..5549c77876 --- /dev/null +++ b/projects/www/src/app/examples/router-store-selectors/src/app/car/car.component.ts @@ -0,0 +1,18 @@ +// #docregion carComponent +import { Component, inject } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { selectCar } from './car.selectors'; +import { AsyncPipe, JsonPipe } from '@angular/common'; + +@Component({ + standalone: true, + selector: 'app-car', + templateUrl: './car.component.html', + styleUrls: ['./car.component.css'], + imports: [AsyncPipe, JsonPipe], +}) +export class CarComponent { + private store = inject(Store); + car$ = this.store.select(selectCar); +} +// #enddocregion carComponent diff --git a/projects/www/src/app/examples/router-store-selectors/src/app/car/car.reducer.ts b/projects/www/src/app/examples/router-store-selectors/src/app/car/car.reducer.ts new file mode 100644 index 0000000000..9be2e87091 --- /dev/null +++ b/projects/www/src/app/examples/router-store-selectors/src/app/car/car.reducer.ts @@ -0,0 +1,25 @@ +// #docregion carReducer +import { createReducer, on } from '@ngrx/store'; +import { EntityState, createEntityAdapter } from '@ngrx/entity'; +import { appInit } from './car.actions'; + +export interface Car { + id: string; + year: string; + make: string; + model: string; +} + +export type CarState = EntityState; + +export const carAdapter = createEntityAdapter({ + selectId: (car) => car.id, +}); + +const initialState = carAdapter.getInitialState(); + +export const reducer = createReducer( + initialState, + on(appInit, (state, { cars }) => carAdapter.addMany(cars, state)) +); +// #enddocregion carReducer diff --git a/projects/www/src/app/examples/router-store-selectors/src/app/car/car.selectors.ts b/projects/www/src/app/examples/router-store-selectors/src/app/car/car.selectors.ts new file mode 100644 index 0000000000..211573923e --- /dev/null +++ b/projects/www/src/app/examples/router-store-selectors/src/app/car/car.selectors.ts @@ -0,0 +1,25 @@ +// #docregion carSelectors +import { createFeatureSelector, createSelector } from '@ngrx/store'; +import { selectRouteParams } from '../router.selectors'; +import { carAdapter, CarState } from './car.reducer'; + +export const carsFeatureSelector = createFeatureSelector('cars'); + +const { selectEntities, selectAll } = carAdapter.getSelectors(); + +export const selectCarEntities = createSelector( + carsFeatureSelector, + selectEntities +); + +export const selectCars = createSelector(carsFeatureSelector, selectAll); + +// you can combine the `selectRouteParams` with `selectCarEntities` +// to get a selector for the active car for this component based +// on the route +export const selectCar = createSelector( + selectCarEntities, + selectRouteParams, + (cars, { carId }) => cars[carId] +); +// #enddocregion carSelectors diff --git a/projects/www/src/app/examples/router-store-selectors/src/app/router.selectors.ts b/projects/www/src/app/examples/router-store-selectors/src/app/router.selectors.ts new file mode 100644 index 0000000000..8ec34baad4 --- /dev/null +++ b/projects/www/src/app/examples/router-store-selectors/src/app/router.selectors.ts @@ -0,0 +1,20 @@ +// #docregion routerSelectors +import { getRouterSelectors, RouterReducerState } from '@ngrx/router-store'; + +// `router` is used as the default feature name. You can use the feature name +// of your choice by creating a feature selector and pass it to the `getRouterSelectors` function +// export const selectRouter = createFeatureSelector('yourFeatureName'); + +export const { + selectCurrentRoute, // select the current route + selectFragment, // select the current route fragment + selectQueryParams, // select the current route query params + selectQueryParam, // factory function to select a query param + selectRouteParams, // select the current route params + selectRouteParam, // factory function to select a route param + selectRouteData, // select the current route data + selectRouteDataParam, // factory function to select a route data param + selectUrl, // select the current url + selectTitle, // select the title if available +} = getRouterSelectors(); +// #enddocregion routerSelectors diff --git a/projects/www/src/app/examples/router-store-selectors/src/index.html b/projects/www/src/app/examples/router-store-selectors/src/index.html new file mode 100644 index 0000000000..6543fe0150 --- /dev/null +++ b/projects/www/src/app/examples/router-store-selectors/src/index.html @@ -0,0 +1,14 @@ + + + + + NgRx Tutorial + + + + + + + + + diff --git a/projects/www/src/app/examples/router-store-selectors/src/main.ts b/projects/www/src/app/examples/router-store-selectors/src/main.ts new file mode 100644 index 0000000000..41893baf92 --- /dev/null +++ b/projects/www/src/app/examples/router-store-selectors/src/main.ts @@ -0,0 +1,5 @@ +import { bootstrapApplication } from '@angular/platform-browser'; +import { AppComponent } from './app/app.component'; +import { appConfig } from './app/app.config'; + +bootstrapApplication(AppComponent, appConfig); diff --git a/projects/www/src/app/examples/store-walkthrough/src/app/app.component.html b/projects/www/src/app/examples/store-walkthrough/src/app/app.component.html new file mode 100644 index 0000000000..ba9276272a --- /dev/null +++ b/projects/www/src/app/examples/store-walkthrough/src/app/app.component.html @@ -0,0 +1,10 @@ +

Oliver Sacks Books Collection

+ + +

Books

+ + +

My Collection

+ + + diff --git a/projects/www/src/app/examples/store-walkthrough/src/app/app.component.spec.ts b/projects/www/src/app/examples/store-walkthrough/src/app/app.component.spec.ts new file mode 100644 index 0000000000..4610454a0f --- /dev/null +++ b/projects/www/src/app/examples/store-walkthrough/src/app/app.component.spec.ts @@ -0,0 +1,160 @@ +import { MemoizedSelector } from '@ngrx/store'; +import { provideMockStore, MockStore } from '@ngrx/store/testing'; +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; + +import { AppState } from './state/app.state'; +import { AppComponent } from './app.component'; +import { addBook, removeBook, retrievedBookList } from './state/books.actions'; +import { BookListComponent } from './book-list/book-list.component'; +import { BookCollectionComponent } from './book-collection/book-collection.component'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { + selectBooks, + selectCollectionIds, + selectBookCollection, +} from './state/books.selectors'; + +describe('AppComponent', () => { + let fixture: ComponentFixture; + let component: AppComponent; + let store: MockStore; + let mockBookCollectionSelector; + let mockBooksSelector; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideMockStore()], + imports: [HttpClientTestingModule], + declarations: [BookListComponent, BookCollectionComponent, AppComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }); + + store = TestBed.inject(MockStore); + fixture = TestBed.createComponent(AppComponent); + component = fixture.componentInstance; + + mockBooksSelector = store.overrideSelector(selectBooks, [ + { + id: 'firstId', + volumeInfo: { + title: 'First Title', + authors: ['First Author'], + }, + }, + { + id: 'secondId', + volumeInfo: { + title: 'Second Title', + authors: ['Second Author'], + }, + }, + { + id: 'thirdId', + volumeInfo: { + title: 'Third Title', + authors: ['Third Author'], + }, + }, + { + id: 'fourthId', + volumeInfo: { + title: 'Fourth Title', + authors: ['Fourth Author'], + }, + }, + ]); + + mockBookCollectionSelector = store.overrideSelector(selectBookCollection, [ + { + id: 'thirdId', + volumeInfo: { + title: 'Third Title', + authors: ['Third Author'], + }, + }, + ]); + + fixture.detectChanges(); + + spyOn(store, 'dispatch').and.callFake(() => {}); + }); + + it('add method should dispatch add action', () => { + component.onAdd('firstId'); + expect(store.dispatch).toHaveBeenCalledWith(addBook({ bookId: 'firstId' })); + }); + + it('remove method should dispatch remove action', () => { + component.onRemove('thirdId'); + expect(store.dispatch).toHaveBeenCalledWith( + removeBook({ bookId: 'thirdId' }) + ); + }); + + it('should render a book list and a book collection', () => { + expect( + fixture.debugElement.queryAll(By.css('.book-list .book-item')).length + ).toBe(4); + expect( + fixture.debugElement.queryAll(By.css('.book-collection .book-item')) + .length + ).toBe(1); + }); + + it('should update the UI when the store changes', () => { + mockBooksSelector.setResult([ + { + id: 'stringA', + volumeInfo: { + title: 'Title A', + authors: ['Author A'], + }, + }, + { + id: 'stringB', + volumeInfo: { + title: 'Title B', + authors: ['Author B'], + }, + }, + { + id: 'stringC', + volumeInfo: { + title: 'Title C', + authors: ['Author C'], + }, + }, + ]); + + mockBookCollectionSelector.setResult([ + { + id: 'stringA', + volumeInfo: { + title: 'Title A', + authors: ['Author A'], + }, + }, + { + id: 'stringB', + volumeInfo: { + title: 'Title B', + authors: ['Author B'], + }, + }, + ]); + + store.refreshState(); + fixture.detectChanges(); + + expect( + fixture.debugElement.queryAll(By.css('.book-list .book-item')).length + ).toBe(3); + + expect( + fixture.debugElement.queryAll(By.css('.book-collection .book-item')) + .length + ).toBe(2); + }); +}); diff --git a/projects/www/src/app/examples/store-walkthrough/src/app/app.component.ts b/projects/www/src/app/examples/store-walkthrough/src/app/app.component.ts new file mode 100644 index 0000000000..9d13cdd66d --- /dev/null +++ b/projects/www/src/app/examples/store-walkthrough/src/app/app.component.ts @@ -0,0 +1,33 @@ +import { Component, OnInit } from '@angular/core'; +import { Store } from '@ngrx/store'; + +import { selectBookCollection, selectBooks } from './state/books.selectors'; +import { BooksActions, BooksApiActions } from './state/books.actions'; +import { GoogleBooksService } from './book-list/books.service'; + +@Component({ + selector: 'app-root', + templateUrl: './app.component.html', +}) +export class AppComponent implements OnInit { + books$ = this.store.select(selectBooks); + bookCollection$ = this.store.select(selectBookCollection); + + onAdd(bookId: string) { + this.store.dispatch(BooksActions.addBook({ bookId })); + } + + onRemove(bookId: string) { + this.store.dispatch(BooksActions.removeBook({ bookId })); + } + + constructor(private booksService: GoogleBooksService, private store: Store) {} + + ngOnInit() { + this.booksService + .getBooks() + .subscribe((books) => + this.store.dispatch(BooksApiActions.retrievedBookList({ books })) + ); + } +} diff --git a/projects/www/src/app/examples/store-walkthrough/src/app/app.module.1.ts b/projects/www/src/app/examples/store-walkthrough/src/app/app.module.1.ts new file mode 100644 index 0000000000..ec0a7a2e51 --- /dev/null +++ b/projects/www/src/app/examples/store-walkthrough/src/app/app.module.1.ts @@ -0,0 +1,20 @@ +// #docregion partialTopLevelImports +import { HttpClientModule } from '@angular/common/http'; +import { booksReducer } from './state/books.reducer'; +import { collectionReducer } from './state/collection.reducer'; +import { StoreModule } from '@ngrx/store'; +// #enddocregion partialTopLevelImports + +// #docregion storeModuleAddToImports +@NgModule({ + imports: [ + BrowserModule, + StoreModule.forRoot({ books: booksReducer, collection: collectionReducer }), + HttpClientModule, + ], + declarations: [AppComponent], + bootstrap: [AppComponent], +}) +export class AppModule {} + +// #enddocregion storeModuleAddToImports diff --git a/projects/www/src/app/examples/store-walkthrough/src/app/app.module.ts b/projects/www/src/app/examples/store-walkthrough/src/app/app.module.ts new file mode 100644 index 0000000000..87666e90ee --- /dev/null +++ b/projects/www/src/app/examples/store-walkthrough/src/app/app.module.ts @@ -0,0 +1,22 @@ +import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; + +import { HttpClientModule } from '@angular/common/http'; +import { booksReducer } from './state/books.reducer'; +import { collectionReducer } from './state/collection.reducer'; +import { StoreModule } from '@ngrx/store'; + +import { AppComponent } from './app.component'; +import { BookListComponent } from './book-list/book-list.component'; +import { BookCollectionComponent } from './book-collection/book-collection.component'; + +@NgModule({ + imports: [ + BrowserModule, + StoreModule.forRoot({ books: booksReducer, collection: collectionReducer }), + HttpClientModule, + ], + declarations: [AppComponent, BookListComponent, BookCollectionComponent], + bootstrap: [AppComponent], +}) +export class AppModule {} diff --git a/projects/www/src/app/examples/store-walkthrough/src/app/book-collection/book-collection.component.css b/projects/www/src/app/examples/store-walkthrough/src/app/book-collection/book-collection.component.css new file mode 100644 index 0000000000..24469d9a62 --- /dev/null +++ b/projects/www/src/app/examples/store-walkthrough/src/app/book-collection/book-collection.component.css @@ -0,0 +1,11 @@ +div { + padding: 10px; +} +span { + margin: 0 10px 0 2px; +} +p { + display: inline-block; + font-style: italic; + margin: 0 0 5px; +} \ No newline at end of file diff --git a/projects/www/src/app/examples/store-walkthrough/src/app/book-collection/book-collection.component.html b/projects/www/src/app/examples/store-walkthrough/src/app/book-collection/book-collection.component.html new file mode 100644 index 0000000000..2a65f9ecc0 --- /dev/null +++ b/projects/www/src/app/examples/store-walkthrough/src/app/book-collection/book-collection.component.html @@ -0,0 +1,10 @@ +
+

{{book.volumeInfo.title}}

by {{book.volumeInfo.authors}} + +
diff --git a/projects/www/src/app/examples/store-walkthrough/src/app/book-collection/book-collection.component.ts b/projects/www/src/app/examples/store-walkthrough/src/app/book-collection/book-collection.component.ts new file mode 100644 index 0000000000..346e94f169 --- /dev/null +++ b/projects/www/src/app/examples/store-walkthrough/src/app/book-collection/book-collection.component.ts @@ -0,0 +1,12 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Book } from '../book-list/books.model'; + +@Component({ + selector: 'app-book-collection', + templateUrl: './book-collection.component.html', + styleUrls: ['./book-collection.component.css'], +}) +export class BookCollectionComponent { + @Input() books: ReadonlyArray = []; + @Output() remove = new EventEmitter(); +} diff --git a/projects/www/src/app/examples/store-walkthrough/src/app/book-list/book-list.component.css b/projects/www/src/app/examples/store-walkthrough/src/app/book-list/book-list.component.css new file mode 100644 index 0000000000..374d31d7be --- /dev/null +++ b/projects/www/src/app/examples/store-walkthrough/src/app/book-list/book-list.component.css @@ -0,0 +1,11 @@ +div { + padding: 10px; +} +span { + margin: 0 10px 0 2px; +} +p { + display: inline-block; + font-style: italic; + margin: 0; +} \ No newline at end of file diff --git a/projects/www/src/app/examples/store-walkthrough/src/app/book-list/book-list.component.html b/projects/www/src/app/examples/store-walkthrough/src/app/book-list/book-list.component.html new file mode 100644 index 0000000000..b3fb0e12b0 --- /dev/null +++ b/projects/www/src/app/examples/store-walkthrough/src/app/book-list/book-list.component.html @@ -0,0 +1,10 @@ +
+

{{book.volumeInfo.title}}

by {{book.volumeInfo.authors}} + +
\ No newline at end of file diff --git a/projects/www/src/app/examples/store-walkthrough/src/app/book-list/book-list.component.ts b/projects/www/src/app/examples/store-walkthrough/src/app/book-list/book-list.component.ts new file mode 100644 index 0000000000..c7a53100ea --- /dev/null +++ b/projects/www/src/app/examples/store-walkthrough/src/app/book-list/book-list.component.ts @@ -0,0 +1,12 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Book } from './books.model'; + +@Component({ + selector: 'app-book-list', + templateUrl: './book-list.component.html', + styleUrls: ['./book-list.component.css'], +}) +export class BookListComponent { + @Input() books: ReadonlyArray = []; + @Output() add = new EventEmitter(); +} diff --git a/projects/www/src/app/examples/store-walkthrough/src/app/book-list/books.model.ts b/projects/www/src/app/examples/store-walkthrough/src/app/book-list/books.model.ts new file mode 100644 index 0000000000..7d3a12a440 --- /dev/null +++ b/projects/www/src/app/examples/store-walkthrough/src/app/book-list/books.model.ts @@ -0,0 +1,7 @@ +export interface Book { + id: string; + volumeInfo: { + title: string; + authors: Array; + }; +} diff --git a/projects/www/src/app/examples/store-walkthrough/src/app/book-list/books.service.ts b/projects/www/src/app/examples/store-walkthrough/src/app/book-list/books.service.ts new file mode 100644 index 0000000000..7e59ee4434 --- /dev/null +++ b/projects/www/src/app/examples/store-walkthrough/src/app/book-list/books.service.ts @@ -0,0 +1,19 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { Book } from './books.model'; + +@Injectable({ providedIn: 'root' }) +export class GoogleBooksService { + constructor(private http: HttpClient) {} + + getBooks(): Observable> { + return this.http + .get<{ items: Book[] }>( + 'https://www.googleapis.com/books/v1/volumes?maxResults=5&orderBy=relevance&q=oliver%20sacks' + ) + .pipe(map((books) => books.items || [])); + } +} diff --git a/projects/www/src/app/examples/store-walkthrough/src/app/state/app.state.ts b/projects/www/src/app/examples/store-walkthrough/src/app/state/app.state.ts new file mode 100644 index 0000000000..b33494b040 --- /dev/null +++ b/projects/www/src/app/examples/store-walkthrough/src/app/state/app.state.ts @@ -0,0 +1,6 @@ +import { Book } from '../book-list/books.model'; + +export interface AppState { + books: ReadonlyArray; + collection: ReadonlyArray; +} diff --git a/projects/www/src/app/examples/store-walkthrough/src/app/state/books.actions.ts b/projects/www/src/app/examples/store-walkthrough/src/app/state/books.actions.ts new file mode 100644 index 0000000000..3c075ef4f7 --- /dev/null +++ b/projects/www/src/app/examples/store-walkthrough/src/app/state/books.actions.ts @@ -0,0 +1,17 @@ +import { createActionGroup, props } from '@ngrx/store'; +import { Book } from '../book-list/books.model'; + +export const BooksActions = createActionGroup({ + source: 'Books', + events: { + 'Add Book': props<{ bookId: string }>(), + 'Remove Book': props<{ bookId: string }>(), + }, +}); + +export const BooksApiActions = createActionGroup({ + source: 'Books API', + events: { + 'Retrieved Book List': props<{ books: ReadonlyArray }>(), + }, +}); diff --git a/projects/www/src/app/examples/store-walkthrough/src/app/state/books.reducer.ts b/projects/www/src/app/examples/store-walkthrough/src/app/state/books.reducer.ts new file mode 100644 index 0000000000..b96101a25a --- /dev/null +++ b/projects/www/src/app/examples/store-walkthrough/src/app/state/books.reducer.ts @@ -0,0 +1,11 @@ +import { createReducer, on } from '@ngrx/store'; + +import { BooksApiActions } from './books.actions'; +import { Book } from '../book-list/books.model'; + +export const initialState: ReadonlyArray = []; + +export const booksReducer = createReducer( + initialState, + on(BooksApiActions.retrievedBookList, (_state, { books }) => books) +); diff --git a/projects/www/src/app/examples/store-walkthrough/src/app/state/books.selectors.ts b/projects/www/src/app/examples/store-walkthrough/src/app/state/books.selectors.ts new file mode 100644 index 0000000000..834850f209 --- /dev/null +++ b/projects/www/src/app/examples/store-walkthrough/src/app/state/books.selectors.ts @@ -0,0 +1,15 @@ +import { createSelector, createFeatureSelector } from '@ngrx/store'; +import { Book } from '../book-list/books.model'; + +export const selectBooks = createFeatureSelector>('books'); + +export const selectCollectionState = + createFeatureSelector>('collection'); + +export const selectBookCollection = createSelector( + selectBooks, + selectCollectionState, + (books, collection) => { + return collection.map((id) => books.find((book) => book.id === id)!); + } +); diff --git a/projects/www/src/app/examples/store-walkthrough/src/app/state/collection.reducer.ts b/projects/www/src/app/examples/store-walkthrough/src/app/state/collection.reducer.ts new file mode 100644 index 0000000000..a4bcd2865b --- /dev/null +++ b/projects/www/src/app/examples/store-walkthrough/src/app/state/collection.reducer.ts @@ -0,0 +1,16 @@ +import { createReducer, on } from '@ngrx/store'; +import { BooksActions } from './books.actions'; + +export const initialState: ReadonlyArray = []; + +export const collectionReducer = createReducer( + initialState, + on(BooksActions.removeBook, (state, { bookId }) => + state.filter((id) => id !== bookId) + ), + on(BooksActions.addBook, (state, { bookId }) => { + if (state.indexOf(bookId) > -1) return state; + + return [...state, bookId]; + }) +); diff --git a/projects/www/src/app/examples/store-walkthrough/src/app/tests/app.component.1.spec.ts b/projects/www/src/app/examples/store-walkthrough/src/app/tests/app.component.1.spec.ts new file mode 100644 index 0000000000..c54b82b2f1 --- /dev/null +++ b/projects/www/src/app/examples/store-walkthrough/src/app/tests/app.component.1.spec.ts @@ -0,0 +1,147 @@ +import { MemoizedSelector } from '@ngrx/store'; +import { Store, StoreModule } from '@ngrx/store'; +import { provideMockStore, MockStore } from '@ngrx/store/testing'; +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; + +import { AppState } from '../state/app.state'; +import { AppComponent } from '../app.component'; +import { + addBook as addAction, + removeBook as removeAction, + retrievedBookList, +} from '../state/books.actions'; +import { BookListComponent } from '../book-list/book-list.component'; +import { BookCollectionComponent } from '../book-collection/book-collection.component'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { + selectBooks, + selectCollectionIds, + selectBookCollection, +} from '../state/books.selectors'; + +describe('AppComponent', () => { + let fixture: ComponentFixture; + let component: AppComponent; + let store: MockStore; + let mockBookCollectionSelector; + let mockBooksSelector; + + TestBed.configureTestingModule({ + providers: [provideMockStore()], + imports: [HttpClientTestingModule], + declarations: [BookListComponent, BookCollectionComponent, AppComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }); + + store = TestBed.inject(MockStore); + fixture = TestBed.createComponent(AppComponent); + component = fixture.componentInstance; + + //#docregion mockSelector + mockBooksSelector = store.overrideSelector(selectBooks, [ + { + id: 'firstId', + volumeInfo: { + title: 'First Title', + authors: ['First Author'], + }, + }, + ]); + + mockBookCollectionSelector = store.overrideSelector(selectBookCollection, []); + + fixture.detectChanges(); + spyOn(store, 'dispatch').and.callFake(() => {}); + + it('should update the UI when the store changes', () => { + mockBooksSelector.setResult([ + { + id: 'firstId', + volumeInfo: { + title: 'First Title', + authors: ['First Author'], + }, + }, + { + id: 'secondId', + volumeInfo: { + title: 'Second Title', + authors: ['Second Author'], + }, + }, + ]); + + mockBookCollectionSelector.setResult([ + { + id: 'firstId', + volumeInfo: { + title: 'First Title', + authors: ['First Author'], + }, + }, + ]); + + store.refreshState(); + fixture.detectChanges(); + + expect( + fixture.debugElement.queryAll(By.css('.book-list .book-item')).length + ).toBe(2); + + expect( + fixture.debugElement.queryAll(By.css('.book-collection .book-item')) + .length + ).toBe(1); + }); + //#enddocregion mockSelector +}); + +//#docregion resetMockSelector +describe('AppComponent reset selectors', () => { + let store: MockStore; + + afterEach(() => { + store?.resetSelectors(); + }); + + it('should return the mocked value', (done: any) => { + TestBed.configureTestingModule({ + providers: [ + provideMockStore({ + selectors: [ + { + selector: selectBooks, + value: [ + { + id: 'mockedId', + volumeInfo: { + title: 'Mocked Title', + authors: ['Mocked Author'], + }, + }, + ], + }, + ], + }), + ], + }); + + store = TestBed.inject(MockStore); + + store.select(selectBooks).subscribe((mockBooks) => { + expect(mockBooks).toEqual([ + { + id: 'mockedId', + volumeInfo: { + title: 'Mocked Title', + authors: ['Mocked Author'], + }, + }, + ]); + done(); + }); + }); +}); +//#enddocregion resetMockSelector diff --git a/projects/www/src/app/examples/store-walkthrough/src/app/tests/integration.spec.ts b/projects/www/src/app/examples/store-walkthrough/src/app/tests/integration.spec.ts new file mode 100644 index 0000000000..18e599d13c --- /dev/null +++ b/projects/www/src/app/examples/store-walkthrough/src/app/tests/integration.spec.ts @@ -0,0 +1,122 @@ +import { + TestBed, + async, + ComponentFixture, + fakeAsync, + tick, +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { of } from 'rxjs'; +import { StoreModule } from '@ngrx/store'; +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; + +import { BookListComponent } from './book-list/book-list.component'; +import { GoogleBooksService } from './book-list/books.service'; +import { BookCollectionComponent } from './book-collection/book-collection.component'; +import { AppComponent } from './app.component'; +import { collectionReducer } from './state/collection.reducer'; +import { booksReducer } from './state/books.reducer'; + +describe('AppComponent Integration Test', () => { + let component: AppComponent; + let fixture: ComponentFixture; + let booksService: GoogleBooksService; + let httpMock: HttpTestingController; + + beforeEach(async((done: any) => { + //#docregion integrate + TestBed.configureTestingModule({ + declarations: [AppComponent, BookListComponent, BookCollectionComponent], + imports: [ + HttpClientTestingModule, + StoreModule.forRoot({ + books: booksReducer, + collection: collectionReducer, + }), + ], + providers: [GoogleBooksService], + }).compileComponents(); + + fixture = TestBed.createComponent(AppComponent); + component = fixture.debugElement.componentInstance; + + fixture.detectChanges(); + //#enddocregion integrate + + booksService = TestBed.get(GoogleBooksService); + httpMock = TestBed.get(HttpTestingController); + + const req = httpMock.expectOne( + 'https://www.googleapis.com/books/v1/volumes?maxResults=5&orderBy=relevance&q=oliver%20sacks' + ); + req.flush({ + items: [ + { + id: 'firstId', + volumeInfo: { + title: 'First Title', + authors: ['First Author'], + }, + }, + { + id: 'secondId', + volumeInfo: { + title: 'Second Title', + authors: ['Second Author'], + }, + }, + ], + }); + + fixture.detectChanges(); + })); + + afterEach(() => { + httpMock.verify(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + //#docregion addTest + describe('buttons should work as expected', () => { + it('should add to collection when add button is clicked and remove from collection when remove button is clicked', () => { + const addButton = getBookList()[1].query( + By.css('[data-test=add-button]') + ); + + click(addButton); + expect(getBookTitle(getCollection()[0])).toBe('Second Title'); + + const removeButton = getCollection()[0].query( + By.css('[data-test=remove-button]') + ); + click(removeButton); + + expect(getCollection().length).toBe(0); + }); + }); + + //functions used in the above test + function getCollection() { + return fixture.debugElement.queryAll(By.css('.book-collection .book-item')); + } + + function getBookList() { + return fixture.debugElement.queryAll(By.css('.book-list .book-item')); + } + + function getBookTitle(element) { + return element.query(By.css('p')).nativeElement.textContent; + } + + function click(element) { + const el: HTMLElement = element.nativeElement; + el.click(); + fixture.detectChanges(); + } + //#enddocregion addTest +}); diff --git a/projects/www/src/app/examples/store-walkthrough/src/index.html b/projects/www/src/app/examples/store-walkthrough/src/index.html new file mode 100644 index 0000000000..371e0a2fb9 --- /dev/null +++ b/projects/www/src/app/examples/store-walkthrough/src/index.html @@ -0,0 +1,13 @@ + + + + + NgRx Tutorial + + + + + + + + \ No newline at end of file diff --git a/projects/www/src/app/examples/store-walkthrough/src/main.ts b/projects/www/src/app/examples/store-walkthrough/src/main.ts new file mode 100644 index 0000000000..2e578fde01 --- /dev/null +++ b/projects/www/src/app/examples/store-walkthrough/src/main.ts @@ -0,0 +1,19 @@ +import './polyfills'; + +import { enableProdMode } from '@angular/core'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { AppModule } from './app/app.module'; + +platformBrowserDynamic() + .bootstrapModule(AppModule) + .then((ref) => { + // Ensure Angular destroys itself on hot reloads. + if (window['ngRef']) { + window['ngRef'].destroy(); + } + window['ngRef'] = ref; + + // Otherwise, log the boot error + }) + .catch((err) => console.error(err)); diff --git a/projects/www/src/app/examples/testing-store/src/.browserslistrc b/projects/www/src/app/examples/testing-store/src/.browserslistrc new file mode 100644 index 0000000000..e67bdb6d8e --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/.browserslistrc @@ -0,0 +1,5 @@ +> 0.5% +last 2 versions +Firefox ESR +not dead +IE 9-11 diff --git a/projects/www/src/app/examples/testing-store/src/app/actions/auth.actions.ts b/projects/www/src/app/examples/testing-store/src/app/actions/auth.actions.ts new file mode 100644 index 0000000000..7da2220f31 --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/actions/auth.actions.ts @@ -0,0 +1,7 @@ +import { createAction, props } from '@ngrx/store'; + +export const login = createAction( + '[Auth] Login', + props<{ username: string }>() +); +export const logout = createAction('[Auth] Logout'); diff --git a/projects/www/src/app/examples/testing-store/src/app/actions/index.ts b/projects/www/src/app/examples/testing-store/src/app/actions/index.ts new file mode 100644 index 0000000000..3ff8decdc3 --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/actions/index.ts @@ -0,0 +1,3 @@ +import * as AuthActions from './auth.actions'; + +export { AuthActions }; diff --git a/projects/www/src/app/examples/testing-store/src/app/app.component.html b/projects/www/src/app/examples/testing-store/src/app/app.component.html new file mode 100644 index 0000000000..7bf6a9555f --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/app.component.html @@ -0,0 +1,16 @@ +

Oliver Sacks Books Collection

+ +

Books

+ + +

My Collection

+ + diff --git a/projects/www/src/app/examples/testing-store/src/app/app.component.spec.ts b/projects/www/src/app/examples/testing-store/src/app/app.component.spec.ts new file mode 100644 index 0000000000..cb685824e0 --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/app.component.spec.ts @@ -0,0 +1,158 @@ +import { provideMockStore, MockStore } from '@ngrx/store/testing'; +import { TestBed, ComponentFixture } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; + +import { AppState } from './state/app.state'; +import { AppComponent } from './app.component'; +import { addBook, removeBook } from './state/books.actions'; +import { BookListComponent } from './book-list/book-list.component'; +import { BookCollectionComponent } from './book-collection/book-collection.component'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { By } from '@angular/platform-browser'; +import { selectBooks, selectBookCollection } from './state/books.selectors'; + +describe('AppComponent', () => { + let fixture: ComponentFixture; + let component: AppComponent; + let store: MockStore; + let mockBookCollectionSelector; + let mockBooksSelector; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideMockStore()], + imports: [HttpClientTestingModule], + declarations: [BookListComponent, BookCollectionComponent, AppComponent], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + }); + + store = TestBed.inject(MockStore); + fixture = TestBed.createComponent(AppComponent); + component = fixture.componentInstance; + + mockBooksSelector = store.overrideSelector(selectBooks, [ + { + id: 'firstId', + volumeInfo: { + title: 'First Title', + authors: ['First Author'], + }, + }, + { + id: 'secondId', + volumeInfo: { + title: 'Second Title', + authors: ['Second Author'], + }, + }, + { + id: 'thirdId', + volumeInfo: { + title: 'Third Title', + authors: ['Third Author'], + }, + }, + { + id: 'fourthId', + volumeInfo: { + title: 'Fourth Title', + authors: ['Fourth Author'], + }, + }, + ]); + + mockBookCollectionSelector = store.overrideSelector(selectBookCollection, [ + { + id: 'thirdId', + volumeInfo: { + title: 'Third Title', + authors: ['Third Author'], + }, + }, + ]); + + fixture.detectChanges(); + spyOn(store, 'dispatch').and.callFake(() => {}); + }); + + afterEach(() => { + TestBed.inject(MockStore)?.resetSelectors(); + }); + + it('add method should dispatch add action', () => { + component.onAdd('firstId'); + expect(store.dispatch).toHaveBeenCalledWith(addBook({ bookId: 'firstId' })); + }); + + it('remove method should dispatch remove action', () => { + component.onRemove('thirdId'); + expect(store.dispatch).toHaveBeenCalledWith( + removeBook({ bookId: 'thirdId' }) + ); + }); + + it('should render a book list and a book collection', () => { + expect( + fixture.debugElement.queryAll(By.css('.book-list .book-item')).length + ).toBe(4); + expect( + fixture.debugElement.queryAll(By.css('.book-collection .book-item')) + .length + ).toBe(1); + }); + + it('should update the UI when the store changes', () => { + mockBooksSelector.setResult([ + { + id: 'firstId', + volumeInfo: { + title: 'First Title', + authors: ['First Author'], + }, + }, + { + id: 'secondId', + volumeInfo: { + title: 'Second Title', + authors: ['Second Author'], + }, + }, + { + id: 'thirdId', + volumeInfo: { + title: 'Third Title', + authors: ['Third Author'], + }, + }, + ]); + + mockBookCollectionSelector.setResult([ + { + id: 'firstId', + volumeInfo: { + title: 'First Title', + authors: ['First Author'], + }, + }, + { + id: 'secondId', + volumeInfo: { + title: 'Second Title', + authors: ['Second Author'], + }, + }, + ]); + + store.refreshState(); + fixture.detectChanges(); + + expect( + fixture.debugElement.queryAll(By.css('.book-list .book-item')).length + ).toBe(3); + + expect( + fixture.debugElement.queryAll(By.css('.book-collection .book-item')) + .length + ).toBe(2); + }); +}); diff --git a/projects/www/src/app/examples/testing-store/src/app/app.component.ts b/projects/www/src/app/examples/testing-store/src/app/app.component.ts new file mode 100644 index 0000000000..4033b0755f --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/app.component.ts @@ -0,0 +1,31 @@ +import { Component, OnInit } from '@angular/core'; +import { Store, select } from '@ngrx/store'; + +import { selectBookCollection, selectBooks } from './state/books.selectors'; +import { retrievedBookList, addBook, removeBook } from './state/books.actions'; +import { GoogleBooksService } from './book-list/books.service'; + +@Component({ + selector: 'app-root', + templateUrl: './app.component.html', +}) +export class AppComponent implements OnInit { + books$ = this.store.pipe(select(selectBooks)); + bookCollection$ = this.store.pipe(select(selectBookCollection)); + + onAdd(bookId) { + this.store.dispatch(addBook({ bookId })); + } + + onRemove(bookId) { + this.store.dispatch(removeBook({ bookId })); + } + + constructor(private booksService: GoogleBooksService, private store: Store) {} + + ngOnInit() { + this.booksService + .getBooks() + .subscribe((Book) => this.store.dispatch(retrievedBookList({ Book }))); + } +} diff --git a/projects/www/src/app/examples/testing-store/src/app/app.module.ts b/projects/www/src/app/examples/testing-store/src/app/app.module.ts new file mode 100644 index 0000000000..b98dea673a --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/app.module.ts @@ -0,0 +1,24 @@ +import { NgModule } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { HttpClient, HttpClientModule } from '@angular/common/http'; + +// #docregion imports +import { booksReducer } from './state/books.reducer'; +import { collectionReducer } from './state/collection.reducer'; +import { StoreModule } from '@ngrx/store'; +// #enddocregion imports + +import { AppComponent } from './app.component'; +import { BookListComponent } from './book-list/book-list.component'; +import { BookCollectionComponent } from './book-collection/book-collection.component'; + +@NgModule({ + imports: [ + BrowserModule, + StoreModule.forRoot({ books: booksReducer, collection: collectionReducer }), + HttpClientModule, + ], + declarations: [AppComponent, BookListComponent, BookCollectionComponent], + bootstrap: [AppComponent], +}) +export class AppModule {} diff --git a/projects/www/src/app/examples/testing-store/src/app/book-collection/book-collection.component.css b/projects/www/src/app/examples/testing-store/src/app/book-collection/book-collection.component.css new file mode 100644 index 0000000000..24469d9a62 --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/book-collection/book-collection.component.css @@ -0,0 +1,11 @@ +div { + padding: 10px; +} +span { + margin: 0 10px 0 2px; +} +p { + display: inline-block; + font-style: italic; + margin: 0 0 5px; +} \ No newline at end of file diff --git a/projects/www/src/app/examples/testing-store/src/app/book-collection/book-collection.component.html b/projects/www/src/app/examples/testing-store/src/app/book-collection/book-collection.component.html new file mode 100644 index 0000000000..2a65f9ecc0 --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/book-collection/book-collection.component.html @@ -0,0 +1,10 @@ +
+

{{book.volumeInfo.title}}

by {{book.volumeInfo.authors}} + +
diff --git a/projects/www/src/app/examples/testing-store/src/app/book-collection/book-collection.component.ts b/projects/www/src/app/examples/testing-store/src/app/book-collection/book-collection.component.ts new file mode 100644 index 0000000000..98903e75ef --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/book-collection/book-collection.component.ts @@ -0,0 +1,12 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Book } from '../book-list/books.model'; + +@Component({ + selector: 'app-book-collection', + templateUrl: './book-collection.component.html', + styleUrls: ['./book-collection.component.css'], +}) +export class BookCollectionComponent { + @Input() books: Array; + @Output() remove = new EventEmitter(); +} diff --git a/projects/www/src/app/examples/testing-store/src/app/book-list/book-list.component.css b/projects/www/src/app/examples/testing-store/src/app/book-list/book-list.component.css new file mode 100644 index 0000000000..374d31d7be --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/book-list/book-list.component.css @@ -0,0 +1,11 @@ +div { + padding: 10px; +} +span { + margin: 0 10px 0 2px; +} +p { + display: inline-block; + font-style: italic; + margin: 0; +} \ No newline at end of file diff --git a/projects/www/src/app/examples/testing-store/src/app/book-list/book-list.component.html b/projects/www/src/app/examples/testing-store/src/app/book-list/book-list.component.html new file mode 100644 index 0000000000..b3fb0e12b0 --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/book-list/book-list.component.html @@ -0,0 +1,10 @@ +
+

{{book.volumeInfo.title}}

by {{book.volumeInfo.authors}} + +
\ No newline at end of file diff --git a/projects/www/src/app/examples/testing-store/src/app/book-list/book-list.component.ts b/projects/www/src/app/examples/testing-store/src/app/book-list/book-list.component.ts new file mode 100644 index 0000000000..7dbcc6cbe2 --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/book-list/book-list.component.ts @@ -0,0 +1,12 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Book } from './books.model'; + +@Component({ + selector: 'app-book-list', + templateUrl: './book-list.component.html', + styleUrls: ['./book-list.component.css'], +}) +export class BookListComponent { + @Input() books: Array; + @Output() add = new EventEmitter(); +} diff --git a/projects/www/src/app/examples/testing-store/src/app/book-list/books.model.ts b/projects/www/src/app/examples/testing-store/src/app/book-list/books.model.ts new file mode 100644 index 0000000000..7d3a12a440 --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/book-list/books.model.ts @@ -0,0 +1,7 @@ +export interface Book { + id: string; + volumeInfo: { + title: string; + authors: Array; + }; +} diff --git a/projects/www/src/app/examples/testing-store/src/app/book-list/books.service.ts b/projects/www/src/app/examples/testing-store/src/app/book-list/books.service.ts new file mode 100644 index 0000000000..7e59ee4434 --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/book-list/books.service.ts @@ -0,0 +1,19 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; + +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { Book } from './books.model'; + +@Injectable({ providedIn: 'root' }) +export class GoogleBooksService { + constructor(private http: HttpClient) {} + + getBooks(): Observable> { + return this.http + .get<{ items: Book[] }>( + 'https://www.googleapis.com/books/v1/volumes?maxResults=5&orderBy=relevance&q=oliver%20sacks' + ) + .pipe(map((books) => books.items || [])); + } +} diff --git a/projects/www/src/app/examples/testing-store/src/app/integration.spec.ts b/projects/www/src/app/examples/testing-store/src/app/integration.spec.ts new file mode 100644 index 0000000000..b320fc5577 --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/integration.spec.ts @@ -0,0 +1,111 @@ +import { TestBed, ComponentFixture, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { StoreModule } from '@ngrx/store'; +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; + +import { BookListComponent } from './book-list/book-list.component'; +import { GoogleBooksService } from './book-list/books.service'; +import { BookCollectionComponent } from './book-collection/book-collection.component'; +import { AppComponent } from './app.component'; +import { collectionReducer } from './state/collection.reducer'; +import { booksReducer } from './state/books.reducer'; + +describe('AppComponent Integration Test', () => { + let component: AppComponent; + let fixture: ComponentFixture; + let httpMock: HttpTestingController; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [AppComponent, BookListComponent, BookCollectionComponent], + imports: [ + HttpClientTestingModule, + StoreModule.forRoot({ + books: booksReducer, + collection: collectionReducer, + }), + ], + providers: [GoogleBooksService], + }).compileComponents(); + + httpMock = TestBed.inject(HttpTestingController); + + fixture = TestBed.createComponent(AppComponent); + component = fixture.debugElement.componentInstance; + + fixture.detectChanges(); + + const req = httpMock.expectOne( + 'https://www.googleapis.com/books/v1/volumes?maxResults=5&orderBy=relevance&q=oliver%20sacks' + ); + req.flush({ + items: [ + { + id: 'firstId', + volumeInfo: { + title: 'First Title', + authors: ['First Author'], + }, + }, + { + id: 'secondId', + volumeInfo: { + title: 'Second Title', + authors: ['Second Author'], + }, + }, + ], + }); + + fixture.detectChanges(); + })); + + afterEach(() => { + httpMock.verify(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + describe('buttons should work as expected', () => { + it('should add to collection when add button is clicked and remove from collection when remove button is clicked', () => { + const addButton = getBookList()[1].query( + By.css('[data-test=add-button]') + ); + + click(addButton); + + expect(getBookTitle(getCollection()[0])).toBe('Second Title'); + + const removeButton = getCollection()[0].query( + By.css('[data-test=remove-button]') + ); + + click(removeButton); + + expect(getCollection().length).toBe(0); + }); + }); + + function getCollection() { + return fixture.debugElement.queryAll(By.css('.book-collection .book-item')); + } + + function getBookList() { + return fixture.debugElement.queryAll(By.css('.book-list .book-item')); + } + + function getBookTitle(element) { + return element.query(By.css('p')).nativeElement.textContent; + } + + function click(element) { + const el: HTMLElement = element.nativeElement; + el.click(); + fixture.detectChanges(); + } +}); diff --git a/projects/www/src/app/examples/testing-store/src/app/reducers/auth.reducer.ts b/projects/www/src/app/examples/testing-store/src/app/reducers/auth.reducer.ts new file mode 100644 index 0000000000..a67e7baee8 --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/reducers/auth.reducer.ts @@ -0,0 +1,18 @@ +import { createReducer, on } from '@ngrx/store'; +import { AuthActions } from '../actions'; + +export interface State { + username: string; +} + +export const initialState: State = { + username: '', +}; + +export const reducer = createReducer( + initialState, + on(AuthActions.login, ({ username }): State => ({ username })), + on(AuthActions.logout, (): State => ({ username: initialState.username })) +); + +export const getUsername = (state: State) => state.username; diff --git a/projects/www/src/app/examples/testing-store/src/app/reducers/index.ts b/projects/www/src/app/examples/testing-store/src/app/reducers/index.ts new file mode 100644 index 0000000000..e406569b10 --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/reducers/index.ts @@ -0,0 +1,22 @@ +import { createSelector, createFeatureSelector } from '@ngrx/store'; +import * as fromAuth from './auth.reducer'; + +export interface AuthState { + status: fromAuth.State; +} + +export interface State { + auth: AuthState; +} + +export const selectAuthState = createFeatureSelector('auth'); + +export const selectAuthStatusState = createSelector( + selectAuthState, + (state: AuthState) => state.status +); + +export const getUsername = createSelector( + selectAuthStatusState, + fromAuth.getUsername +); diff --git a/projects/www/src/app/examples/testing-store/src/app/state/app.state.ts b/projects/www/src/app/examples/testing-store/src/app/state/app.state.ts new file mode 100644 index 0000000000..b33494b040 --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/state/app.state.ts @@ -0,0 +1,6 @@ +import { Book } from '../book-list/books.model'; + +export interface AppState { + books: ReadonlyArray; + collection: ReadonlyArray; +} diff --git a/projects/www/src/app/examples/testing-store/src/app/state/books.actions.ts b/projects/www/src/app/examples/testing-store/src/app/state/books.actions.ts new file mode 100644 index 0000000000..015f097b31 --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/state/books.actions.ts @@ -0,0 +1,16 @@ +import { createAction, props } from '@ngrx/store'; + +export const addBook = createAction( + '[Book List] Add Book', + props<{ bookId }>() +); + +export const removeBook = createAction( + '[Book Collection] Remove Book', + props<{ bookId }>() +); + +export const retrievedBookList = createAction( + '[Book List/API] Retrieve Books Success', + props<{ Book }>() +); diff --git a/projects/www/src/app/examples/testing-store/src/app/state/books.reducer.spec.ts b/projects/www/src/app/examples/testing-store/src/app/state/books.reducer.spec.ts new file mode 100644 index 0000000000..0a7b7e0036 --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/state/books.reducer.spec.ts @@ -0,0 +1,37 @@ +import * as fromReducer from './books.reducer'; +import { retrievedBookList } from './books.actions'; +import { Book } from '../book-list/books.model'; + +describe('BooksReducer', () => { + describe('unknown action', () => { + it('should return the default state', () => { + const { initialState } = fromReducer; + const action = { + type: 'Unknown', + }; + const state = fromReducer.booksReducer(initialState, action); + + expect(state).toBe(initialState); + }); + }); + + describe('retrievedBookList action', () => { + it('should retrieve all books and update the state in an immutable way', () => { + const { initialState } = fromReducer; + const newState: Array = [ + { + id: 'firstId', + volumeInfo: { + title: 'First Title', + authors: ['First Author'], + }, + }, + ]; + const action = retrievedBookList({ Book: newState }); + const state = fromReducer.booksReducer(initialState, action); + + expect(state).toEqual(newState); + expect(state).not.toBe(initialState); + }); + }); +}); diff --git a/projects/www/src/app/examples/testing-store/src/app/state/books.reducer.ts b/projects/www/src/app/examples/testing-store/src/app/state/books.reducer.ts new file mode 100644 index 0000000000..e835d5df09 --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/state/books.reducer.ts @@ -0,0 +1,11 @@ +import { createReducer, on, Action } from '@ngrx/store'; + +import { retrievedBookList } from './books.actions'; +import { Book } from '../book-list/books.model'; + +export const initialState: ReadonlyArray = []; + +export const booksReducer = createReducer( + initialState, + on(retrievedBookList, (state, { Book }) => [...Book]) +); diff --git a/projects/www/src/app/examples/testing-store/src/app/state/books.selectors.spec.ts b/projects/www/src/app/examples/testing-store/src/app/state/books.selectors.spec.ts new file mode 100644 index 0000000000..f2e6cd6fb7 --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/state/books.selectors.spec.ts @@ -0,0 +1,39 @@ +import { selectBooks, selectBookCollection } from './books.selectors'; +import { AppState } from './app.state'; + +describe('Selectors', () => { + const initialState: AppState = { + books: [ + { + id: 'firstId', + volumeInfo: { + title: 'First Title', + authors: ['First Author'], + }, + }, + { + id: 'secondId', + volumeInfo: { + title: 'Second Title', + authors: ['Second Author'], + }, + }, + ], + collection: ['firstId'], + }; + + it('should select the book list', () => { + const result = selectBooks.projector(initialState.books); + expect(result.length).toEqual(2); + expect(result[1].id).toEqual('secondId'); + }); + + it('should select the book collection', () => { + const result = selectBookCollection.projector( + initialState.books, + initialState.collection + ); + expect(result.length).toEqual(1); + expect(result[0].id).toEqual('firstId'); + }); +}); diff --git a/projects/www/src/app/examples/testing-store/src/app/state/books.selectors.ts b/projects/www/src/app/examples/testing-store/src/app/state/books.selectors.ts new file mode 100644 index 0000000000..834850f209 --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/state/books.selectors.ts @@ -0,0 +1,15 @@ +import { createSelector, createFeatureSelector } from '@ngrx/store'; +import { Book } from '../book-list/books.model'; + +export const selectBooks = createFeatureSelector>('books'); + +export const selectCollectionState = + createFeatureSelector>('collection'); + +export const selectBookCollection = createSelector( + selectBooks, + selectCollectionState, + (books, collection) => { + return collection.map((id) => books.find((book) => book.id === id)!); + } +); diff --git a/projects/www/src/app/examples/testing-store/src/app/state/collection.reducer.spec.ts b/projects/www/src/app/examples/testing-store/src/app/state/collection.reducer.spec.ts new file mode 100644 index 0000000000..ccf2097f96 --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/state/collection.reducer.spec.ts @@ -0,0 +1,47 @@ +import * as fromReducer from './collection.reducer'; +import { addBook, removeBook } from './books.actions'; + +describe('CollectionReducer', () => { + describe('unknown action', () => { + it('should return the previous state', () => { + const { initialState } = fromReducer; + const action = { + type: 'Unknown', + }; + const state = fromReducer.collectionReducer(initialState, action); + + expect(state).toBe(initialState); + }); + }); + + describe('add action', () => { + it('should add an item from the book list and update the state in an immutable way', () => { + const initialState: Array = ['firstId', 'secondId']; + + const action = addBook({ bookId: 'thirdId' }); + const state = fromReducer.collectionReducer(initialState, action); + + expect(state[2]).toBe('thirdId'); + }); + + it('should not add a bookId to collection when that bookId is already in the collection', () => { + const initialState: Array = ['firstId', 'secondId']; + + const action = addBook({ bookId: 'secondId' }); + const state = fromReducer.collectionReducer(initialState, action); + + expect(state[2]).toEqual(undefined); + expect(state[1]).toBe('secondId'); + }); + }); + + describe('remove action', () => { + it('should remove the selected book from the collection update the state in an immutable way', () => { + const initialState: Array = ['firstId', 'secondId']; + const action = removeBook({ bookId: 'secondId' }); + const state = fromReducer.collectionReducer(initialState, action); + + expect(state[1]).toEqual(undefined); + }); + }); +}); diff --git a/projects/www/src/app/examples/testing-store/src/app/state/collection.reducer.ts b/projects/www/src/app/examples/testing-store/src/app/state/collection.reducer.ts new file mode 100644 index 0000000000..25bcf4585e --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/state/collection.reducer.ts @@ -0,0 +1,14 @@ +import { createReducer, on, Action } from '@ngrx/store'; +import { addBook, removeBook } from './books.actions'; + +export const initialState: ReadonlyArray = []; + +export const collectionReducer = createReducer( + initialState, + on(removeBook, (state, { bookId }) => state.filter((id) => id !== bookId)), + on(addBook, (state, { bookId }) => { + if (state.indexOf(bookId) > -1) return state; + + return [...state, bookId]; + }) +); diff --git a/projects/www/src/app/examples/testing-store/src/app/user-greeting.component.spec.ts b/projects/www/src/app/examples/testing-store/src/app/user-greeting.component.spec.ts new file mode 100644 index 0000000000..7df8ca52cb --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/user-greeting.component.spec.ts @@ -0,0 +1,40 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { MemoizedSelector } from '@ngrx/store'; +import { provideMockStore, MockStore } from '@ngrx/store/testing'; +import { UserGreetingComponent } from './user-greeting.component'; +import * as fromAuth from './reducers'; + +describe('User Greeting Component', () => { + let fixture: ComponentFixture; + let mockStore: MockStore; + let mockUsernameSelector: MemoizedSelector; + const queryDivText = () => + fixture.debugElement.queryAll(By.css('div'))[0].nativeElement.textContent; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideMockStore()], + declarations: [UserGreetingComponent], + }); + + fixture = TestBed.createComponent(UserGreetingComponent); + mockStore = TestBed.inject(MockStore); + mockUsernameSelector = mockStore.overrideSelector( + fromAuth.getUsername, + 'John' + ); + fixture.detectChanges(); + }); + + it('should greet John when the username is John', () => { + expect(queryDivText()).toBe('Greetings, John!'); + }); + + it('should greet Brandon when the username is Brandon', () => { + mockUsernameSelector.setResult('Brandon'); + mockStore.refreshState(); + fixture.detectChanges(); + expect(queryDivText()).toBe('Greetings, Brandon!'); + }); +}); diff --git a/projects/www/src/app/examples/testing-store/src/app/user-greeting.component.ts b/projects/www/src/app/examples/testing-store/src/app/user-greeting.component.ts new file mode 100644 index 0000000000..3ca6d0ab4b --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/app/user-greeting.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { Store } from '@ngrx/store'; +import * as fromAuth from './reducers'; + +@Component({ + selector: 'user-greeting', + template: `
Greetings, {{ username$ | async }}!
`, +}) +export class UserGreetingComponent { + username$ = this.store.select(fromAuth.getUsername); + + constructor(private store: Store) {} +} diff --git a/projects/www/src/app/examples/testing-store/src/index.html b/projects/www/src/app/examples/testing-store/src/index.html new file mode 100644 index 0000000000..f902fba22d --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/index.html @@ -0,0 +1,13 @@ + + + + + NgRx Tutorial + + + + + + + + diff --git a/projects/www/src/app/examples/testing-store/src/main-test.ts b/projects/www/src/app/examples/testing-store/src/main-test.ts new file mode 100644 index 0000000000..2e578fde01 --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/main-test.ts @@ -0,0 +1,19 @@ +import './polyfills'; + +import { enableProdMode } from '@angular/core'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { AppModule } from './app/app.module'; + +platformBrowserDynamic() + .bootstrapModule(AppModule) + .then((ref) => { + // Ensure Angular destroys itself on hot reloads. + if (window['ngRef']) { + window['ngRef'].destroy(); + } + window['ngRef'] = ref; + + // Otherwise, log the boot error + }) + .catch((err) => console.error(err)); diff --git a/projects/www/src/app/examples/testing-store/src/main.ts b/projects/www/src/app/examples/testing-store/src/main.ts new file mode 100644 index 0000000000..27d21e9dac --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/main.ts @@ -0,0 +1,12 @@ +// main app entry point +import { enableProdMode } from '@angular/core'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { AppModule } from './app/app.module'; +import { environment } from './environments/environment'; + +if (environment.production) { + enableProdMode(); +} + +platformBrowserDynamic().bootstrapModule(AppModule); diff --git a/projects/www/src/app/examples/testing-store/src/styles.css b/projects/www/src/app/examples/testing-store/src/styles.css new file mode 100644 index 0000000000..eb1cca00b7 --- /dev/null +++ b/projects/www/src/app/examples/testing-store/src/styles.css @@ -0,0 +1,4 @@ +/* Master Styles */ +* { + font-family: Arial, Helvetica, sans-serif; +} diff --git a/projects/www/src/app/pages/guide/component-store/usage.md b/projects/www/src/app/pages/guide/component-store/usage.md index 03f13c2353..66b0e024f8 100644 --- a/projects/www/src/app/pages/guide/component-store/usage.md +++ b/projects/www/src/app/pages/guide/component-store/usage.md @@ -82,7 +82,7 @@ You can see the full example at StackBlitz: + @@ -104,12 +104,6 @@ First, the state for the component needs to be identified. In `SlideToggleCompon path="component-store-slide-toggle/src/app/slide-toggle.component.ts" region="state"> -```ts -export interface SlideToggleState { - checked: boolean; -} -``` - Then we need to provide `ComponentStore` in the component's providers, so that each new instance of `SlideToggleComponent` has its own `ComponentStore`. It also has to be injected into the constructor. @@ -120,17 +114,11 @@ In this example `ComponentStore` is provided directly in the component. This wor - -```ts -@Component({ - selector: 'mat-slide-toggle', - templateUrl: 'slide-toggle.html', -``` - Next, the default state for the component needs to be set. It could be done lazily, however it needs to be done before any of `updater`s are executed, because they rely on the state to be present and would throw an error if the state is not initialized by the time they are invoked. @@ -152,17 +140,6 @@ When it is called with a callback, the state is updated. path="component-store-slide-toggle/src/app/slide-toggle.component.ts" region="init"> -```ts -constructor( - private readonly componentStore: ComponentStore -) { - // set defaults - this.componentStore.setState({ - checked: false, - }); -} -``` - #### Step 2. Updating state @@ -173,17 +150,11 @@ In the slide-toggle example, the state is updated either through `@Input` or by When a user clicks the toggle (triggering a 'change' event), instead of calling the same updater directly, the `onChangeEvent` effect is called. This is done because we also need to have the side-effect of `event.source.stopPropagation` to prevent this event from bubbling up (slide-toggle output event in named 'change' as well) and only after that the `setChecked` updater is called with the value of the input element. - -```ts -@Input() set checked(value: boolean) { - this.setChecked(value); - } -``` - #### Step 3. Reading the state @@ -198,14 +169,6 @@ Finally, the state is aggregated with selectors into two properties: path="component-store-slide-toggle/src/app/slide-toggle.component.ts" region="selector"> -```ts -// Observable used instead of EventEmitter - @Output() readonly change = this.componentStore.select((state) => ({ - source: this, - checked: state.checked, - })); -``` - This example does not have a lot of business logic, however it is still fully reactive. @@ -239,7 +202,7 @@ You can see the examples at StackBlitz: - + @@ -263,24 +226,6 @@ With `ComponentStore` extracted into `PaginatorStore`, the developer is now usin path="component-store-paginator-service/src/app/paginator.component.ts" region="inputs"> -```ts -@Input() set pageIndex(value: string | number) { - this.paginatorStore.setPageIndex(value); - } - - @Input() set length(value: string | number) { - this.paginatorStore.setLength(value); - } - - @Input() set pageSize(value: string | number) { - this.paginatorStore.setPageSize(value); - } - - @Input() set pageSizeOptions(value: readonly number[]) { - this.paginatorStore.setPageSizeOptions(value); - } -``` - Not all `updater`s have to be called in the `@Input`. For example, `changePageSize` is called from the template. @@ -292,24 +237,6 @@ Effects are used to perform additional validation and get extra information from path="component-store-paginator-service/src/app/paginator.component.ts" region="updating-state"> -```ts -changePageSize(newPageSize: number) { - this.paginatorStore.changePageSize(newPageSize); - } - nextPage() { - this.paginatorStore.nextPage(); - } - firstPage() { - this.paginatorStore.firstPage(); - } - previousPage() { - this.paginatorStore.previousPage(); - } - lastPage() { - this.paginatorStore.lastPage(); - } -``` - #### Reading the state diff --git a/projects/www/src/app/pages/guide/data/entity-actions.md b/projects/www/src/app/pages/guide/data/entity-actions.md index 8d0f8a9ca9..f388c2f2d8 100644 --- a/projects/www/src/app/pages/guide/data/entity-actions.md +++ b/projects/www/src/app/pages/guide/data/entity-actions.md @@ -21,7 +21,7 @@ It's optional `payload` carries the message data necessary to perform the operat An `EntityAction` is a super-set of the _NgRx `Action`_. It has additional properties that guide NgRx Data's handling of the action. Here's the full interface. - + ```ts export interface EntityAction

extends Action { @@ -32,7 +32,7 @@ export interface EntityAction

extends Action { - + ```ts export interface EntityActionPayload

diff --git a/projects/www/src/app/pages/guide/data/entity-change-tracker.md b/projects/www/src/app/pages/guide/data/entity-change-tracker.md index d2202dade2..14611ffe16 100644 --- a/projects/www/src/app/pages/guide/data/entity-change-tracker.md +++ b/projects/www/src/app/pages/guide/data/entity-change-tracker.md @@ -93,7 +93,7 @@ described [below](#disable-change-tracking). A `changeState` map adheres to the following interface - + ```ts export interface ChangeState { @@ -144,7 +144,7 @@ Delete (remove) is a special case with special rules. Here are the most important `EntityOps` that record an entity in the `changeState` map: - + ```ts // Save operations when isOptimistic flag is true @@ -188,7 +188,7 @@ Operations that put that entity in the store also remove it from the `changeStat Here are the operations that remove one or more specified entities from the `changeState` map. - + ```ts QUERY_ALL_SUCCESS; @@ -216,7 +216,7 @@ UNDO_MANY; The `EntityOps` that replace or remove every entity in the collection also reset the `changeState` to an empty object. All entities in the collection (if any) become "unchanged". - + ```ts ADD_ALL; diff --git a/projects/www/src/app/pages/guide/data/entity-dataservice.md b/projects/www/src/app/pages/guide/data/entity-dataservice.md index 5b0402ea90..946084a448 100644 --- a/projects/www/src/app/pages/guide/data/entity-dataservice.md +++ b/projects/www/src/app/pages/guide/data/entity-dataservice.md @@ -176,7 +176,7 @@ To support this feature, we 'll create a `HeroDataService` class that implements In the sample app the `HeroDataService` derives from the NgRx Data `DefaultDataService` in order to leverage its base functionality. It only overrides what it really needs. - + ```ts import { Injectable } from '@angular/core'; @@ -239,7 +239,7 @@ Finally, we must tell NgRx Data about this new data service. The sample app provides `HeroDataService` and registers it by calling the `registerService()` method on the `EntityDataService` in the app's _entity store module_: - + ```ts import { EntityDataService } from '@ngrx/data'; // <-- import the NgRx Data data service registry diff --git a/projects/www/src/app/pages/guide/data/save-entities.md b/projects/www/src/app/pages/guide/data/save-entities.md index bb61bfe2bd..8c5012ad0f 100644 --- a/projects/www/src/app/pages/guide/data/save-entities.md +++ b/projects/www/src/app/pages/guide/data/save-entities.md @@ -49,7 +49,7 @@ We assume a server is ready to handle such a request. First create the changes (each a `ChangeSetItem`) for the `ChangeSet`. - + ```ts import { ChangeSetOperation } from '@ngrx/data'; @@ -134,7 +134,7 @@ This complicated dance is standard NgRx. Fortunately, all you have to know is th The `ChangeSet` interface is a simple structure with only one critical property, `changes`, which holds the entity data to save. - + ```ts export interface ChangeSet { diff --git a/projects/www/src/app/pages/guide/router-store/selectors.md b/projects/www/src/app/pages/guide/router-store/selectors.md index dfd3b0c714..3825de08f8 100644 --- a/projects/www/src/app/pages/guide/router-store/selectors.md +++ b/projects/www/src/app/pages/guide/router-store/selectors.md @@ -20,117 +20,18 @@ You can see the full example at StackBlitz: -```ts -import { - getRouterSelectors, - RouterReducerState, -} from '@ngrx/router-store'; - -// `router` is used as the default feature name. You can use the feature name -// of your choice by creating a feature selector and pass it to the `getRouterSelectors` function -// export const selectRouter = createFeatureSelector('yourFeatureName'); - -export const { - selectCurrentRoute, // select the current route - selectFragment, // select the current route fragment - selectQueryParams, // select the current route query params - selectQueryParam, // factory function to select a query param - selectRouteParams, // select the current route params - selectRouteParam, // factory function to select a route param - selectRouteData, // select the current route data - selectRouteDataParam, // factory function to select a route data param - selectUrl, // select the current url - selectTitle, // select the title if available -} = getRouterSelectors(); -``` - -```ts -import { createReducer, on } from '@ngrx/store'; -import { EntityState, createEntityAdapter } from '@ngrx/entity'; -import { appInit } from './car.actions'; - -export interface Car { - id: string; - year: string; - make: string; - model: string; -} - -export type CarState = EntityState; - -export const carAdapter = createEntityAdapter({ - selectId: (car) => car.id, -}); - -const initialState = carAdapter.getInitialState(); - -export const reducer = createReducer( - initialState, - on(appInit, (state, { cars }) => carAdapter.addMany(cars, state)) -); -``` - -```ts -import { createFeatureSelector, createSelector } from '@ngrx/store'; -import { selectRouteParams } from '../router.selectors'; -import { carAdapter, CarState } from './car.reducer'; - -export const carsFeatureSelector = - createFeatureSelector('cars'); - -const { selectEntities, selectAll } = carAdapter.getSelectors(); - -export const selectCarEntities = createSelector( - carsFeatureSelector, - selectEntities -); - -export const selectCars = createSelector( - carsFeatureSelector, - selectAll -); - -// you can combine the `selectRouteParams` with `selectCarEntities` -// to get a selector for the active car for this component based -// on the route -export const selectCar = createSelector( - selectCarEntities, - selectRouteParams, - (cars, { carId }) => cars[carId] -); -``` - -```ts -import { Component, inject } from '@angular/core'; -import { Store } from '@ngrx/store'; -import { selectCar } from './car.selectors'; -import { AsyncPipe, JsonPipe } from '@angular/common'; - -@Component({ - standalone: true, - selector: 'app-car', - templateUrl: './car.component.html', - styleUrls: ['./car.component.css'], - imports: [AsyncPipe, JsonPipe], -}) -export class CarComponent { - private store = inject(Store); - car$ = this.store.select(selectCar); -} -``` - ## Extracting all params in the current route diff --git a/projects/www/src/app/pages/guide/signals/signal-state.md b/projects/www/src/app/pages/guide/signals/signal-state.md index 0c7e41ed40..420669dfbd 100644 --- a/projects/www/src/app/pages/guide/signals/signal-state.md +++ b/projects/www/src/app/pages/guide/signals/signal-state.md @@ -121,7 +121,7 @@ patchState(userState, setFirstName('Stevie'), setAdmin()); ### Example 1: SignalState in a Component - + ```ts import { ChangeDetectionStrategy, Component } from '@angular/core'; @@ -159,7 +159,7 @@ export class Counter { ### Example 2: SignalState in a Service - + ```ts diff --git a/projects/www/src/app/pages/guide/signals/signal-store/linked-state.md b/projects/www/src/app/pages/guide/signals/signal-store/linked-state.md index cb9865d615..4f493e8012 100644 --- a/projects/www/src/app/pages/guide/signals/signal-store/linked-state.md +++ b/projects/www/src/app/pages/guide/signals/signal-store/linked-state.md @@ -10,7 +10,7 @@ These linked state slices become an integral part of the SignalStore's state and When a computation function is provided, the SignalStore wraps it in a `linkedSignal()`. As a result, the linked state slice is updated automatically whenever any of its dependent signals change. - + ```ts diff --git a/projects/www/src/app/pages/guide/signals/signal-store/private-store-members.md b/projects/www/src/app/pages/guide/signals/signal-store/private-store-members.md index 9c523ff8a9..c03cc60af8 100644 --- a/projects/www/src/app/pages/guide/signals/signal-store/private-store-members.md +++ b/projects/www/src/app/pages/guide/signals/signal-store/private-store-members.md @@ -3,7 +3,7 @@ SignalStore allows defining private members that cannot be accessed from outside the store by using the `_` prefix. This includes root-level state slices, properties, and methods. - + ```ts diff --git a/projects/www/src/app/pages/guide/store/index.md b/projects/www/src/app/pages/guide/store/index.md index bee15234fc..9e5811553e 100644 --- a/projects/www/src/app/pages/guide/store/index.md +++ b/projects/www/src/app/pages/guide/store/index.md @@ -43,76 +43,24 @@ The following tutorial shows you how to manage the state of a counter, and how t -```ts -import { createAction } from '@ngrx/store'; - -export const increment = createAction( - '[Counter Component] Increment' -); -export const decrement = createAction( - '[Counter Component] Decrement' -); -export const reset = createAction('[Counter Component] Reset'); -``` - 3. Define a reducer function to handle changes in the counter value based on the provided actions. -```ts -import { createReducer, on } from '@ngrx/store'; -import { increment, decrement, reset } from './counter.actions'; - -export const initialState = 0; - -export const counterReducer = createReducer( - initialState, - on(increment, (state) => state + 1), - on(decrement, (state) => state - 1), - on(reset, (state) => 0) -); -``` - 4. Import the `StoreModule` from `@ngrx/store` and the `counter.reducer` file. -```ts -import { StoreModule } from '@ngrx/store'; -import { counterReducer } from './counter.reducer'; -``` - 5. Add the `StoreModule.forRoot` function in the `imports` array of your `AppModule` with an object containing the `count` and the `counterReducer` that manages the state of the counter. The `StoreModule.forRoot()` method registers the global providers needed to access the `Store` throughout your application. -```ts -import { BrowserModule } from '@angular/platform-browser'; -import { NgModule } from '@angular/core'; - -import { AppComponent } from './app.component'; - -import { StoreModule } from '@ngrx/store'; -import { counterReducer } from './counter.reducer'; - -@NgModule({ - declarations: [AppComponent], - imports: [ - BrowserModule, - StoreModule.forRoot({ count: counterReducer }), - ], - providers: [], - bootstrap: [AppComponent], -}) -export class AppModule {} -``` - 6. Create a new file called `my-counter.component.ts` in a folder named `my-counter` within the `app` folder that will define a new component called `MyCounterComponent`. This component will render buttons that allow the user to change the count state. Also, create the `my-counter.component.html` file within this same folder. diff --git a/projects/www/src/app/pages/guide/store/testing.md b/projects/www/src/app/pages/guide/store/testing.md index 3baaf02e25..71b7df1a3a 100644 --- a/projects/www/src/app/pages/guide/store/testing.md +++ b/projects/www/src/app/pages/guide/store/testing.md @@ -73,94 +73,10 @@ Usage: -```ts -import { createSelector, createFeatureSelector } from '@ngrx/store'; -import { Book } from '../book-list/books.model'; - -export const selectBooks = - createFeatureSelector>('books'); - -export const selectCollectionState = - createFeatureSelector>('collection'); - -export const selectBookCollection = createSelector( - selectBooks, - selectCollectionState, - (books, collection) => { - return collection.map( - (id) => books.find((book) => book.id === id)! - ); - } -); -``` - -```ts -mockBooksSelector = store.overrideSelector(selectBooks, [ - { - id: 'firstId', - volumeInfo: { - title: 'First Title', - authors: ['First Author'], - }, - }, -]); - -mockBookCollectionSelector = store.overrideSelector( - selectBookCollection, - [] -); - -fixture.detectChanges(); -spyOn(store, 'dispatch').and.callFake(() => {}); - -it('should update the UI when the store changes', () => { - mockBooksSelector.setResult([ - { - id: 'firstId', - volumeInfo: { - title: 'First Title', - authors: ['First Author'], - }, - }, - { - id: 'secondId', - volumeInfo: { - title: 'Second Title', - authors: ['Second Author'], - }, - }, - ]); - - mockBookCollectionSelector.setResult([ - { - id: 'firstId', - volumeInfo: { - title: 'First Title', - authors: ['First Author'], - }, - }, - ]); - - store.refreshState(); - fixture.detectChanges(); - - expect( - fixture.debugElement.queryAll(By.css('.book-list .book-item')) - .length - ).toBe(2); - - expect( - fixture.debugElement.queryAll( - By.css('.book-collection .book-item') - ).length - ).toBe(1); -}); -``` - In this example based on the [walkthrough](guide/store/walkthrough), we mock the `selectBooks` selector by using `overrideSelector`, passing in the `selectBooks` selector with a default mocked return value of an array of books. Similarly, we mock the `selectBookCollection` selector and pass the selector together with another array. In the test, we use `setResult()` to update the mock selectors to return new array values, then we use `MockStore.refreshState()` to trigger an emission from the `selectBooks` and `selectBookCollection` selectors. @@ -169,54 +85,6 @@ You can reset selectors by calling the `MockStore.resetSelectors()` method in th -```ts -describe('AppComponent reset selectors', () => { - let store: MockStore; - - afterEach(() => { - store?.resetSelectors(); - }); - - it('should return the mocked value', (done: any) => { - TestBed.configureTestingModule({ - providers: [ - provideMockStore({ - selectors: [ - { - selector: selectBooks, - value: [ - { - id: 'mockedId', - volumeInfo: { - title: 'Mocked Title', - authors: ['Mocked Author'], - }, - }, - ], - }, - ], - }), - ], - }); - - store = TestBed.inject(MockStore); - - store.select(selectBooks).subscribe((mockBooks) => { - expect(mockBooks).toEqual([ - { - id: 'mockedId', - volumeInfo: { - title: 'Mocked Title', - authors: ['Mocked Author'], - }, - }, - ]); - done(); - }); - }); -}); -``` - Try the . @@ -227,78 +95,12 @@ An integration test should verify that the `Store` coherently works together wit -```ts -TestBed.configureTestingModule({ - declarations: [ - AppComponent, - BookListComponent, - BookCollectionComponent, - ], - imports: [ - HttpClientTestingModule, - StoreModule.forRoot({ - books: booksReducer, - collection: collectionReducer, - }), - ], - providers: [GoogleBooksService], -}).compileComponents(); - -fixture = TestBed.createComponent(AppComponent); -component = fixture.debugElement.componentInstance; - -fixture.detectChanges(); -``` - The integration test sets up the dependent `Store` by importing the `StoreModule`. In this part of the example, we assert that clicking the `add` button dispatches the corresponding action and is correctly emitted by the `collection` selector. -```ts -describe('buttons should work as expected', () => { - it('should add to collection when add button is clicked and remove from collection when remove button is clicked', () => { - const addButton = getBookList()[1].query( - By.css('[data-test=add-button]') - ); - - click(addButton); - expect(getBookTitle(getCollection()[0])).toBe('Second Title'); - - const removeButton = getCollection()[0].query( - By.css('[data-test=remove-button]') - ); - click(removeButton); - - expect(getCollection().length).toBe(0); - }); -}); - -//functions used in the above test -function getCollection() { - return fixture.debugElement.queryAll( - By.css('.book-collection .book-item') - ); -} - -function getBookList() { - return fixture.debugElement.queryAll( - By.css('.book-list .book-item') - ); -} - -function getBookTitle(element) { - return element.query(By.css('p')).nativeElement.textContent; -} - -function click(element) { - const el: HTMLElement = element.nativeElement; - el.click(); - fixture.detectChanges(); -} -``` - ### Testing selectors @@ -307,48 +109,6 @@ You can use the projector function used by the selector by accessing the `.proje -```ts -import { selectBooks, selectBookCollection } from './books.selectors'; -import { AppState } from './app.state'; - -describe('Selectors', () => { - const initialState: AppState = { - books: [ - { - id: 'firstId', - volumeInfo: { - title: 'First Title', - authors: ['First Author'], - }, - }, - { - id: 'secondId', - volumeInfo: { - title: 'Second Title', - authors: ['Second Author'], - }, - }, - ], - collection: ['firstId'], - }; - - it('should select the book list', () => { - const result = selectBooks.projector(initialState.books); - expect(result.length).toEqual(2); - expect(result[1].id).toEqual('secondId'); - }); - - it('should select the book collection', () => { - const result = selectBookCollection.projector( - initialState.books, - initialState.collection - ); - expect(result.length).toEqual(1); - expect(result[0].id).toEqual('firstId'); - }); -}); -``` - ### Testing reducers @@ -357,46 +117,6 @@ The following example tests the `booksReducer` from the [walkthrough](guide/stor -```ts -import * as fromReducer from './books.reducer'; -import { retrievedBookList } from './books.actions'; -import { Book } from '../book-list/books.model'; - -describe('BooksReducer', () => { - describe('unknown action', () => { - it('should return the default state', () => { - const { initialState } = fromReducer; - const action = { - type: 'Unknown', - }; - const state = fromReducer.booksReducer(initialState, action); - - expect(state).toBe(initialState); - }); - }); - - describe('retrievedBookList action', () => { - it('should retrieve all books and update the state in an immutable way', () => { - const { initialState } = fromReducer; - const newState: Array = [ - { - id: 'firstId', - volumeInfo: { - title: 'First Title', - authors: ['First Author'], - }, - }, - ]; - const action = retrievedBookList({ Book: newState }); - const state = fromReducer.booksReducer(initialState, action); - - expect(state).toEqual(newState); - expect(state).not.toBe(initialState); - }); - }); -}); -``` - ### Testing without `TestBed` diff --git a/projects/www/src/app/pages/guide/store/walkthrough.md b/projects/www/src/app/pages/guide/store/walkthrough.md index 0b25c64f95..a43f1d1200 100644 --- a/projects/www/src/app/pages/guide/store/walkthrough.md +++ b/projects/www/src/app/pages/guide/store/walkthrough.md @@ -8,233 +8,64 @@ The following example more extensively utilizes the key concepts of store to man -```ts -export interface Book { - id: string; - volumeInfo: { - title: string; - authors: Array; - }; -} -``` - 2. Right click on the `app` folder to create a state management folder `state`. Within the new folder, create a new file `books.actions.ts` to describe the book actions. Book actions include the book list retrieval, and the add and remove book actions. -```ts -import { createActionGroup, props } from '@ngrx/store'; -import { Book } from '../book-list/books.model'; - -export const BooksActions = createActionGroup({ - source: 'Books', - events: { - 'Add Book': props<{ bookId: string }>(), - 'Remove Book': props<{ bookId: string }>(), - }, -}); - -export const BooksApiActions = createActionGroup({ - source: 'Books API', - events: { - 'Retrieved Book List': props<{ books: ReadonlyArray }>(), - }, -}); -``` - 3. Right click on the `state` folder and create a new file labeled `books.reducer.ts`. Within this file, define a reducer function to handle the retrieval of the book list from the state and consequently, update the state. -```ts -import { createReducer, on } from '@ngrx/store'; - -import { BooksApiActions } from './books.actions'; -import { Book } from '../book-list/books.model'; - -export const initialState: ReadonlyArray = []; - -export const booksReducer = createReducer( - initialState, - on(BooksApiActions.retrievedBookList, (_state, { books }) => books) -); -``` - 4. Create another file named `collection.reducer.ts` in the `state` folder to handle actions that alter the user's book collection. Define a reducer function that handles the add action by appending the book's ID to the collection, including a condition to avoid duplicate book IDs. Define the same reducer to handle the remove action by filtering the collection array with the book ID. -```ts -import { createReducer, on } from '@ngrx/store'; -import { BooksActions } from './books.actions'; - -export const initialState: ReadonlyArray = []; - -export const collectionReducer = createReducer( - initialState, - on(BooksActions.removeBook, (state, { bookId }) => - state.filter((id) => id !== bookId) - ), - on(BooksActions.addBook, (state, { bookId }) => { - if (state.indexOf(bookId) > -1) return state; - - return [...state, bookId]; - }) -); -``` - 5. Import the `StoreModule` from `@ngrx/store` and the `books.reducer` and `collection.reducer` file. -```ts -import { HttpClientModule } from '@angular/common/http'; -import { booksReducer } from './state/books.reducer'; -import { collectionReducer } from './state/collection.reducer'; -import { StoreModule } from '@ngrx/store'; -``` - 6. Add the `StoreModule.forRoot` function in the `imports` array of your `AppModule` with an object containing the `books` and `booksReducer`, as well as the `collection` and `collectionReducer` that manage the state of the book list and the collection. The `StoreModule.forRoot()` method registers the global providers needed to access the `Store` throughout your application. -```ts -@NgModule({ - imports: [ - BrowserModule, - StoreModule.forRoot({ - books: booksReducer, - collection: collectionReducer, - }), - HttpClientModule, - ], - declarations: [AppComponent], - bootstrap: [AppComponent], -}) -export class AppModule {} -``` - 7. Create the book list and collection selectors to ensure we get the correct information from the store. As you can see, the `selectBookCollection` selector combines two other selectors in order to build its return value. -```ts -import { createSelector, createFeatureSelector } from '@ngrx/store'; -import { Book } from '../book-list/books.model'; - -export const selectBooks = - createFeatureSelector>('books'); - -export const selectCollectionState = - createFeatureSelector>('collection'); - -export const selectBookCollection = createSelector( - selectBooks, - selectCollectionState, - (books, collection) => { - return collection.map( - (id) => books.find((book) => book.id === id)! - ); - } -); -``` - 8. In the `book-list` folder, we want to have a service that fetches the data needed for the book list from an API. Create a file in the `book-list` folder named `books.service.ts`, which will call the Google Books API and return a list of books. -```ts -import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; - -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; -import { Book } from './books.model'; - -@Injectable({ providedIn: 'root' }) -export class GoogleBooksService { - constructor(private http: HttpClient) {} - - getBooks(): Observable> { - return this.http - .get<{ items: Book[] }>( - 'https://www.googleapis.com/books/v1/volumes?maxResults=5&orderBy=relevance&q=oliver%20sacks' - ) - .pipe(map((books) => books.items || [])); - } -} -``` - 9. In the same folder (`book-list`), create the `BookListComponent` with the following template. Update the `BookListComponent` class to dispatch the `add` event. -```html -

-

{{book.volumeInfo.title}}

- by {{book.volumeInfo.authors}} - -
-``` -
-```ts -import { - Component, - EventEmitter, - Input, - Output, -} from '@angular/core'; -import { Book } from './books.model'; - -@Component({ - selector: 'app-book-list', - templateUrl: './book-list.component.html', - styleUrls: ['./book-list.component.css'], -}) -export class BookListComponent { - @Input() books: ReadonlyArray = []; - @Output() add = new EventEmitter(); -} -``` - 10. Create a new _Component_ named `book-collection` in the `app` folder. Update the `BookCollectionComponent` template and class. -```html -
-

{{book.volumeInfo.title}}

- by {{book.volumeInfo.authors}} - -
-``` -
@@ -265,109 +96,16 @@ export class BookCollectionComponent { -```html -

Books

- - -

My Collection

- - -``` -
-```ts -import { NgModule } from '@angular/core'; -import { BrowserModule } from '@angular/platform-browser'; - -import { HttpClientModule } from '@angular/common/http'; -import { booksReducer } from './state/books.reducer'; -import { collectionReducer } from './state/collection.reducer'; -import { StoreModule } from '@ngrx/store'; - -import { AppComponent } from './app.component'; -import { BookListComponent } from './book-list/book-list.component'; -import { BookCollectionComponent } from './book-collection/book-collection.component'; - -@NgModule({ - imports: [ - BrowserModule, - StoreModule.forRoot({ - books: booksReducer, - collection: collectionReducer, - }), - HttpClientModule, - ], - declarations: [ - AppComponent, - BookListComponent, - BookCollectionComponent, - ], - bootstrap: [AppComponent], -}) -export class AppModule {} -``` - 12. In the `AppComponent` class, add the selectors and corresponding actions to dispatch on `add` or `remove` method calls. Then subscribe to the Google Books API in order to update the state. (This should probably be handled by NgRx Effects, which you can read about [here](guide/effects). For the sake of this demo, NgRx Effects is not being included). -```ts -import { Component, OnInit } from '@angular/core'; -import { Store } from '@ngrx/store'; - -import { - selectBookCollection, - selectBooks, -} from './state/books.selectors'; -import { BooksActions, BooksApiActions } from './state/books.actions'; -import { GoogleBooksService } from './book-list/books.service'; - -@Component({ - selector: 'app-root', - templateUrl: './app.component.html', -}) -export class AppComponent implements OnInit { - books$ = this.store.select(selectBooks); - bookCollection$ = this.store.select(selectBookCollection); - - onAdd(bookId: string) { - this.store.dispatch(BooksActions.addBook({ bookId })); - } - - onRemove(bookId: string) { - this.store.dispatch(BooksActions.removeBook({ bookId })); - } - - constructor( - private booksService: GoogleBooksService, - private store: Store - ) {} - - ngOnInit() { - this.booksService - .getBooks() - .subscribe((books) => - this.store.dispatch( - BooksApiActions.retrievedBookList({ books }) - ) - ); - } -} -``` - And that's it! Click the add and remove buttons to change the state. diff --git a/projects/www/src/shared/ngrx-shiki-theme.ts b/projects/www/src/shared/ngrx-shiki-theme.ts new file mode 100644 index 0000000000..7379fb0c12 --- /dev/null +++ b/projects/www/src/shared/ngrx-shiki-theme.ts @@ -0,0 +1,99 @@ +export const ngrxTheme = { + name: 'ngrx-theme', + fg: '#abb2bf', + bg: '#rgba(0, 0, 0, 0.24)', + settings: [ + { + name: 'Comments / Quotes', + scope: ['comment', 'punctuation.definition.comment', 'markup.quote'], + settings: { foreground: '#5c6370', fontStyle: 'italic' }, + }, + { + name: 'Keywords / Doctags / Formula', + scope: ['keyword', 'storage.type', 'storage.modifier', 'storage.control'], + settings: { foreground: '#fface6' }, + }, + { + name: 'Sections / Deletions / Tags / Function name', + scope: [ + 'entity.name.section', + 'markup.heading', + 'markup.deleted', + 'variable.language', + 'entity.name.function', + 'entity.name.tag', + ], + settings: { foreground: '#e06c75' }, + }, + { + name: 'Literals', + scope: ['constant.language', 'support.constant'], + settings: { foreground: '#56b6c2' }, + }, + { + name: 'Strings / Regex / Added / Attributes', + scope: [ + 'string', + 'string.regexp', + 'constant.character.escape', + 'markup.inserted', + 'entity.other.attribute-name', + 'string.template', + ], + settings: { foreground: '#98c379' }, + }, + { + name: 'Numbers / Types / Classes / Vars', + scope: [ + 'constant.numeric', + 'variable.other.readwrite', + 'support.type', + 'support.class', + 'variable.other.constant', + ], + settings: { foreground: '#ffb871' }, + }, + { + name: 'Symbols / Meta / Links', + scope: [ + 'entity.name.type', + 'meta.import', + 'meta.export', + 'markup.list.bullet', + 'markup.link', + 'string.other.link', + ], + settings: { foreground: '#61aeee' }, + }, + { + name: 'Builtins / Class Titles', + scope: [ + 'support.function.builtin', + 'support.type.builtin', + 'entity.name.type.class', + 'meta.class', + ], + settings: { foreground: '#ffdcbe' }, + }, + { + name: 'Emphasis', + scope: ['markup.italic'], + settings: { fontStyle: 'italic' }, + }, + { + name: 'Strong', + scope: ['markup.bold'], + settings: { fontStyle: 'bold' }, + }, + { + name: 'Underline Link', + scope: ['markup.underline.link'], + settings: { fontStyle: 'underline', foreground: '#61aeee' }, + }, + { + name: 'Default', + scope: ['source', 'text'], + settings: { foreground: '#abb2bf' }, + }, + ], +}; diff --git a/projects/www/vite.config.ts b/projects/www/vite.config.ts index f7d01082b3..27e22c4084 100644 --- a/projects/www/vite.config.ts +++ b/projects/www/vite.config.ts @@ -3,6 +3,7 @@ import analog from '@analogjs/platform'; import { defineConfig, splitVendorChunkPlugin } from 'vite'; import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; import ngrxStackblitzPlugin from './src/tools/vite-ngrx-stackblits.plugin'; +import { ngrxTheme } from './src/shared/ngrx-shiki-theme'; // https://vitejs.dev/config/ export default defineConfig(({ mode }) => { @@ -30,8 +31,12 @@ export default defineConfig(({ mode }) => { content: { highlighter: 'shiki', shikiOptions: { + highlight: { + theme: 'ngrx-theme', + }, highlighter: { additionalLangs: ['sh'], + themes: [ngrxTheme], }, }, },