Skip to content

Commit

Permalink
feat: add dataservice feature
Browse files Browse the repository at this point in the history
  • Loading branch information
manfredsteyer committed Dec 22, 2023
1 parent dbd4152 commit c6334de
Show file tree
Hide file tree
Showing 34 changed files with 1,607 additions and 20 deletions.
11 changes: 11 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"files.exclude": {
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/CVS": true,
"**/.DS_Store": true,
"**/Thumbs.db": true
},
"hide-files.files": []
}
144 changes: 144 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,147 @@ export const FlightStore = signalStore(
);
```

## DataService `withDataService()`

`withDataService()` allows to connect a Data Service to the store:

This gives you a store for a CRUD use case:

```typescript
export const SimpleFlightBookingStore = signalStore(
{ providedIn: 'root' },
withCallState(),
withEntities<Flight>(),
withDataService({
dataServiceType: FlightService,
filter: { from: 'Paris', to: 'New York' },
}),
withUndoRedo(),
);
```

The features ``withCallState`` and ``withUndoRedo`` are optional, but when present, they enrich each other.

The Data Service needs to implement the ``DataService`` interface:

```typescript
@Injectable({
providedIn: 'root'
})
export class FlightService implements DataService<Flight, FlightFilter> {
loadById(id: EntityId): Promise<Flight> { ... }
load(filter: FlightFilter): Promise<Flight[]> { ... }

create(entity: Flight): Promise<Flight> { ... }
update(entity: Flight): Promise<Flight> { ... }
delete(entity: Flight): Promise<void> { ... }
[...]
}
```

Once the store is defined, it gives its consumers numerous signals and methods they just need to delegate to:

```typescript
@Component(...)
export class FlightSearchSimpleComponent {
private store = inject(SimpleFlightBookingStore);

from = this.store.filter.from;
to = this.store.filter.to;
flights = this.store.entities;
selected = this.store.selectedEntities;
selectedIds = this.store.selectedIds;

loading = this.store.loading;

canUndo = this.store.canUndo;
canRedo = this.store.canRedo;

async search() {
this.store.load();
}

undo(): void {
this.store.undo();
}

redo(): void {
this.store.redo();
}

updateCriteria(from: string, to: string): void {
this.store.updateFilter({ from, to });
}

updateBasket(id: number, selected: boolean): void {
this.store.updateSelected(id, selected);
}

}
```

## DataService with Dynamic Properties

To avoid naming conflicts, the properties set up by ``withDataService`` and the connected features can be configured in a typesafe way:

```typescript
export const FlightBookingStore = signalStore(
{ providedIn: 'root' },
withCallState({
collection: 'flight'
}),
withEntities({
entity: type<Flight>(),
collection: 'flight'
}),
withDataService({
dataServiceType: FlightService,
filter: { from: 'Graz', to: 'Hamburg' },
collection: 'flight'
}),
withUndoRedo({
collections: ['flight'],
}),
);
```

This setup makes them use ``flight`` as part of the used property names. As these implementations respect the Type Script type system, the compiler will make sure these properties are used in a typesafe way:

```typescript
@Component(...)
export class FlightSearchDynamicComponent {
private store = inject(FlightBookingStore);

from = this.store.flightFilter.from;
to = this.store.flightFilter.to;
flights = this.store.flightEntities;
selected = this.store.selectedFlightEntities;
selectedIds = this.store.selectedFlightIds;

loading = this.store.flightLoading;

canUndo = this.store.canUndo;
canRedo = this.store.canRedo;

async search() {
this.store.loadFlightEntities();
}

undo(): void {
this.store.undo();
}

redo(): void {
this.store.redo();
}

updateCriteria(from: string, to: string): void {
this.store.updateFlightFilter({ from, to });
}

updateBasket(id: number, selected: boolean): void {
this.store.updateSelectedFlightEntities(id, selected);
}

}
```
2 changes: 2 additions & 0 deletions apps/demo/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
"apps/demo/src/assets"
],
"styles": [
"node_modules/@angular-architects/paper-design/assets/css/bootstrap.css",
"node_modules/@angular-architects/paper-design/assets/scss/paper-dashboard.scss",
"@angular/material/prebuilt-themes/deeppurple-amber.css",
"apps/demo/src/styles.css"
],
Expand Down
1 change: 0 additions & 1 deletion apps/demo/src/app/app.component.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
.actions{
display: flex;
align-items: center;

}
27 changes: 22 additions & 5 deletions apps/demo/src/app/app.component.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
<ul>
<li><a routerLink="/todo">Todo - DevTools Showcase</a></li>
<li><a routerLink="/flight-search">Flight Search - withRedux Showcase</a></li>
</ul>
<demo-sidebar-cmp>

<router-outlet />
<div class="nav">
<mat-nav-list>
<a mat-list-item routerLink="/todo">DevTools</a>
<a mat-list-item routerLink="/flight-search">withRedux</a>
<a mat-list-item routerLink="/flight-search-data-service-simple">withDataService (Simple)</a>
<a mat-list-item routerLink="/flight-search-data-service-dynamic">withDataService (Dynamic)</a>

</mat-nav-list>
</div>

<div class="content">
<mat-toolbar color="primary">
<span>NGRX Toolkit Demo</span>
</mat-toolbar>

<div class="app-container">
<router-outlet></router-outlet>
</div>
</div>

</demo-sidebar-cmp>
30 changes: 19 additions & 11 deletions apps/demo/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,27 @@ import { Todo, TodoStore } from './todo-store';
import { MatIconModule } from '@angular/material/icon';
import { CategoryStore } from './category.store';
import { RouterLink, RouterOutlet } from '@angular/router';
import { SidebarComponent } from "./core/sidebar/sidebar.component";
import { CommonModule } from '@angular/common';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatListItem, MatListModule } from '@angular/material/list';

@Component({
selector: 'demo-root',
templateUrl: './app.component.html',
standalone: true,
imports: [
MatTableModule,
MatCheckboxModule,
MatIconModule,
RouterLink,
RouterOutlet,
],
styleUrl: './app.component.css',
selector: 'demo-root',
templateUrl: './app.component.html',
standalone: true,
styleUrl: './app.component.css',
imports: [
MatTableModule,
MatCheckboxModule,
MatIconModule,
MatListModule,
RouterLink,
RouterOutlet,
SidebarComponent,
CommonModule,
MatToolbarModule,
]
})
export class AppComponent {
todoStore = inject(TodoStore);
Expand Down
10 changes: 7 additions & 3 deletions apps/demo/src/app/app.config.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { ApplicationConfig, importProvidersFrom } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { appRoutes } from './app.routes';
import { provideClientHydration } from '@angular/platform-browser';
import { provideAnimations } from '@angular/platform-browser/animations';
import { provideHttpClient } from '@angular/common/http';
import { LayoutModule } from '@angular/cdk/layout';

export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(),
provideRouter(appRoutes),
provideRouter(appRoutes,
withComponentInputBinding()),
provideAnimations(),
provideHttpClient(),
importProvidersFrom(LayoutModule),

],
};
9 changes: 9 additions & 0 deletions apps/demo/src/app/app.routes.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import { Route } from '@angular/router';
import { TodoComponent } from './todo/todo.component';
import { FlightSearchComponent } from './flight-search/flight-search.component';
import { FlightSearchSimpleComponent } from './flight-search-data-service-simple/flight-search-simple.component';
import { FlightEditSimpleComponent } from './flight-search-data-service-simple/flight-edit-simple.component';
import { FlightSearchDynamicComponent } from './flight-search-data-service-dynamic/flight-search.component';
import { FlightEditDynamicComponent } from './flight-search-data-service-dynamic/flight-edit.component';

export const appRoutes: Route[] = [
{ path: 'todo', component: TodoComponent },
{ path: 'flight-search', component: FlightSearchComponent },
{ path: 'flight-search-data-service-simple', component: FlightSearchSimpleComponent },
{ path: 'flight-edit-simple/:id', component: FlightEditSimpleComponent },
{ path: 'flight-search-data-service-dynamic', component: FlightSearchDynamicComponent },
{ path: 'flight-edit-dynamic/:id', component: FlightEditDynamicComponent },

];
21 changes: 21 additions & 0 deletions apps/demo/src/app/core/sidebar/sidebar.component.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
.sidenav-container {
height: 100%;
}

.sidenav {
width: 300px;
}

.sidenav .mat-toolbar {
background: inherit;
}

.mat-toolbar.mat-primary {
position: sticky;
top: 0;
z-index: 1;
}

.app-container {
padding: 20px;
}
16 changes: 16 additions & 0 deletions apps/demo/src/app/core/sidebar/sidebar.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<mat-sidenav-container class="sidenav-container">
<mat-sidenav #drawer class="sidenav" fixedInViewport
[attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
[mode]="(isHandset$ | async) ? 'over' : 'side'"
[opened]="(isHandset$ | async) === false">
<mat-toolbar>Menu</mat-toolbar>

<ng-content select=".nav"></ng-content>

</mat-sidenav>
<mat-sidenav-content>

<ng-content select=".content"></ng-content>

</mat-sidenav-content>
</mat-sidenav-container>
37 changes: 37 additions & 0 deletions apps/demo/src/app/core/sidebar/sidebar.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { CommonModule } from '@angular/common';
import { Component, Inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { MatSidenavModule } from '@angular/material/sidenav';
import { MatToolbarModule } from '@angular/material/toolbar';
import { RouterModule } from '@angular/router';
import { map, shareReplay } from 'rxjs';

@Component({
standalone: true,
selector: 'demo-sidebar-cmp',
imports: [
RouterModule,
CommonModule,
MatToolbarModule,
MatButtonModule,
MatSidenavModule,
MatIconModule,
MatListModule,
],
templateUrl: './sidebar.component.html',
styleUrls: ['./sidebar.component.css']
})
export class SidebarComponent {
isHandset$ = this.breakpointObserver.observe(Breakpoints.Handset)
.pipe(
map(result => result.matches),
shareReplay()
);

constructor(
@Inject(BreakpointObserver) private breakpointObserver: BreakpointObserver) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { FlightService } from '../shared/flight.service';

import {
signalStore, type,
} from '@ngrx/signals';

import { withEntities } from '@ngrx/signals/entities';
import { withCallState, withDataService, withUndoRedo } from 'ngrx-toolkit';
import { Flight } from '../shared/flight';

export const FlightBookingStore = signalStore(
{ providedIn: 'root' },
withCallState({
collection: 'flight'
}),
withEntities({
entity: type<Flight>(),
collection: 'flight'
}),
withDataService({
dataServiceType: FlightService,
filter: { from: 'Paris', to: 'New York' },
collection: 'flight'
}),
withUndoRedo({
collections: ['flight'],
}),
);
Loading

0 comments on commit c6334de

Please sign in to comment.