Skip to content

Commit c6334de

Browse files
committed
feat: add dataservice feature
1 parent dbd4152 commit c6334de

34 files changed

+1607
-20
lines changed

.vscode/settings.json

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"files.exclude": {
3+
"**/.git": true,
4+
"**/.svn": true,
5+
"**/.hg": true,
6+
"**/CVS": true,
7+
"**/.DS_Store": true,
8+
"**/Thumbs.db": true
9+
},
10+
"hide-files.files": []
11+
}

README.md

+144
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,147 @@ export const FlightStore = signalStore(
7474
);
7575
```
7676

77+
## DataService `withDataService()`
78+
79+
`withDataService()` allows to connect a Data Service to the store:
80+
81+
This gives you a store for a CRUD use case:
82+
83+
```typescript
84+
export const SimpleFlightBookingStore = signalStore(
85+
{ providedIn: 'root' },
86+
withCallState(),
87+
withEntities<Flight>(),
88+
withDataService({
89+
dataServiceType: FlightService,
90+
filter: { from: 'Paris', to: 'New York' },
91+
}),
92+
withUndoRedo(),
93+
);
94+
```
95+
96+
The features ``withCallState`` and ``withUndoRedo`` are optional, but when present, they enrich each other.
97+
98+
The Data Service needs to implement the ``DataService`` interface:
99+
100+
```typescript
101+
@Injectable({
102+
providedIn: 'root'
103+
})
104+
export class FlightService implements DataService<Flight, FlightFilter> {
105+
loadById(id: EntityId): Promise<Flight> { ... }
106+
load(filter: FlightFilter): Promise<Flight[]> { ... }
107+
108+
create(entity: Flight): Promise<Flight> { ... }
109+
update(entity: Flight): Promise<Flight> { ... }
110+
delete(entity: Flight): Promise<void> { ... }
111+
[...]
112+
}
113+
```
114+
115+
Once the store is defined, it gives its consumers numerous signals and methods they just need to delegate to:
116+
117+
```typescript
118+
@Component(...)
119+
export class FlightSearchSimpleComponent {
120+
private store = inject(SimpleFlightBookingStore);
121+
122+
from = this.store.filter.from;
123+
to = this.store.filter.to;
124+
flights = this.store.entities;
125+
selected = this.store.selectedEntities;
126+
selectedIds = this.store.selectedIds;
127+
128+
loading = this.store.loading;
129+
130+
canUndo = this.store.canUndo;
131+
canRedo = this.store.canRedo;
132+
133+
async search() {
134+
this.store.load();
135+
}
136+
137+
undo(): void {
138+
this.store.undo();
139+
}
140+
141+
redo(): void {
142+
this.store.redo();
143+
}
144+
145+
updateCriteria(from: string, to: string): void {
146+
this.store.updateFilter({ from, to });
147+
}
148+
149+
updateBasket(id: number, selected: boolean): void {
150+
this.store.updateSelected(id, selected);
151+
}
152+
153+
}
154+
```
155+
156+
## DataService with Dynamic Properties
157+
158+
To avoid naming conflicts, the properties set up by ``withDataService`` and the connected features can be configured in a typesafe way:
159+
160+
```typescript
161+
export const FlightBookingStore = signalStore(
162+
{ providedIn: 'root' },
163+
withCallState({
164+
collection: 'flight'
165+
}),
166+
withEntities({
167+
entity: type<Flight>(),
168+
collection: 'flight'
169+
}),
170+
withDataService({
171+
dataServiceType: FlightService,
172+
filter: { from: 'Graz', to: 'Hamburg' },
173+
collection: 'flight'
174+
}),
175+
withUndoRedo({
176+
collections: ['flight'],
177+
}),
178+
);
179+
```
180+
181+
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:
182+
183+
```typescript
184+
@Component(...)
185+
export class FlightSearchDynamicComponent {
186+
private store = inject(FlightBookingStore);
187+
188+
from = this.store.flightFilter.from;
189+
to = this.store.flightFilter.to;
190+
flights = this.store.flightEntities;
191+
selected = this.store.selectedFlightEntities;
192+
selectedIds = this.store.selectedFlightIds;
193+
194+
loading = this.store.flightLoading;
195+
196+
canUndo = this.store.canUndo;
197+
canRedo = this.store.canRedo;
198+
199+
async search() {
200+
this.store.loadFlightEntities();
201+
}
202+
203+
undo(): void {
204+
this.store.undo();
205+
}
206+
207+
redo(): void {
208+
this.store.redo();
209+
}
210+
211+
updateCriteria(from: string, to: string): void {
212+
this.store.updateFlightFilter({ from, to });
213+
}
214+
215+
updateBasket(id: number, selected: boolean): void {
216+
this.store.updateSelectedFlightEntities(id, selected);
217+
}
218+
219+
}
220+
```

apps/demo/project.json

+2
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
"apps/demo/src/assets"
2525
],
2626
"styles": [
27+
"node_modules/@angular-architects/paper-design/assets/css/bootstrap.css",
28+
"node_modules/@angular-architects/paper-design/assets/scss/paper-dashboard.scss",
2729
"@angular/material/prebuilt-themes/deeppurple-amber.css",
2830
"apps/demo/src/styles.css"
2931
],

apps/demo/src/app/app.component.css

-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
.actions{
22
display: flex;
33
align-items: center;
4-
54
}

apps/demo/src/app/app.component.html

+22-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,23 @@
1-
<ul>
2-
<li><a routerLink="/todo">Todo - DevTools Showcase</a></li>
3-
<li><a routerLink="/flight-search">Flight Search - withRedux Showcase</a></li>
4-
</ul>
1+
<demo-sidebar-cmp>
52

6-
<router-outlet />
3+
<div class="nav">
4+
<mat-nav-list>
5+
<a mat-list-item routerLink="/todo">DevTools</a>
6+
<a mat-list-item routerLink="/flight-search">withRedux</a>
7+
<a mat-list-item routerLink="/flight-search-data-service-simple">withDataService (Simple)</a>
8+
<a mat-list-item routerLink="/flight-search-data-service-dynamic">withDataService (Dynamic)</a>
9+
10+
</mat-nav-list>
11+
</div>
12+
13+
<div class="content">
14+
<mat-toolbar color="primary">
15+
<span>NGRX Toolkit Demo</span>
16+
</mat-toolbar>
17+
18+
<div class="app-container">
19+
<router-outlet></router-outlet>
20+
</div>
21+
</div>
22+
23+
</demo-sidebar-cmp>

apps/demo/src/app/app.component.ts

+19-11
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,27 @@ import { Todo, TodoStore } from './todo-store';
66
import { MatIconModule } from '@angular/material/icon';
77
import { CategoryStore } from './category.store';
88
import { RouterLink, RouterOutlet } from '@angular/router';
9+
import { SidebarComponent } from "./core/sidebar/sidebar.component";
10+
import { CommonModule } from '@angular/common';
11+
import { MatToolbarModule } from '@angular/material/toolbar';
12+
import { MatListItem, MatListModule } from '@angular/material/list';
913

1014
@Component({
11-
selector: 'demo-root',
12-
templateUrl: './app.component.html',
13-
standalone: true,
14-
imports: [
15-
MatTableModule,
16-
MatCheckboxModule,
17-
MatIconModule,
18-
RouterLink,
19-
RouterOutlet,
20-
],
21-
styleUrl: './app.component.css',
15+
selector: 'demo-root',
16+
templateUrl: './app.component.html',
17+
standalone: true,
18+
styleUrl: './app.component.css',
19+
imports: [
20+
MatTableModule,
21+
MatCheckboxModule,
22+
MatIconModule,
23+
MatListModule,
24+
RouterLink,
25+
RouterOutlet,
26+
SidebarComponent,
27+
CommonModule,
28+
MatToolbarModule,
29+
]
2230
})
2331
export class AppComponent {
2432
todoStore = inject(TodoStore);

apps/demo/src/app/app.config.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1-
import { ApplicationConfig } from '@angular/core';
2-
import { provideRouter } from '@angular/router';
1+
import { ApplicationConfig, importProvidersFrom } from '@angular/core';
2+
import { provideRouter, withComponentInputBinding } from '@angular/router';
33
import { appRoutes } from './app.routes';
44
import { provideClientHydration } from '@angular/platform-browser';
55
import { provideAnimations } from '@angular/platform-browser/animations';
66
import { provideHttpClient } from '@angular/common/http';
7+
import { LayoutModule } from '@angular/cdk/layout';
78

89
export const appConfig: ApplicationConfig = {
910
providers: [
1011
provideClientHydration(),
11-
provideRouter(appRoutes),
12+
provideRouter(appRoutes,
13+
withComponentInputBinding()),
1214
provideAnimations(),
1315
provideHttpClient(),
16+
importProvidersFrom(LayoutModule),
17+
1418
],
1519
};

apps/demo/src/app/app.routes.ts

+9
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
import { Route } from '@angular/router';
22
import { TodoComponent } from './todo/todo.component';
33
import { FlightSearchComponent } from './flight-search/flight-search.component';
4+
import { FlightSearchSimpleComponent } from './flight-search-data-service-simple/flight-search-simple.component';
5+
import { FlightEditSimpleComponent } from './flight-search-data-service-simple/flight-edit-simple.component';
6+
import { FlightSearchDynamicComponent } from './flight-search-data-service-dynamic/flight-search.component';
7+
import { FlightEditDynamicComponent } from './flight-search-data-service-dynamic/flight-edit.component';
48

59
export const appRoutes: Route[] = [
610
{ path: 'todo', component: TodoComponent },
711
{ path: 'flight-search', component: FlightSearchComponent },
12+
{ path: 'flight-search-data-service-simple', component: FlightSearchSimpleComponent },
13+
{ path: 'flight-edit-simple/:id', component: FlightEditSimpleComponent },
14+
{ path: 'flight-search-data-service-dynamic', component: FlightSearchDynamicComponent },
15+
{ path: 'flight-edit-dynamic/:id', component: FlightEditDynamicComponent },
16+
817
];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
.sidenav-container {
2+
height: 100%;
3+
}
4+
5+
.sidenav {
6+
width: 300px;
7+
}
8+
9+
.sidenav .mat-toolbar {
10+
background: inherit;
11+
}
12+
13+
.mat-toolbar.mat-primary {
14+
position: sticky;
15+
top: 0;
16+
z-index: 1;
17+
}
18+
19+
.app-container {
20+
padding: 20px;
21+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<mat-sidenav-container class="sidenav-container">
2+
<mat-sidenav #drawer class="sidenav" fixedInViewport
3+
[attr.role]="(isHandset$ | async) ? 'dialog' : 'navigation'"
4+
[mode]="(isHandset$ | async) ? 'over' : 'side'"
5+
[opened]="(isHandset$ | async) === false">
6+
<mat-toolbar>Menu</mat-toolbar>
7+
8+
<ng-content select=".nav"></ng-content>
9+
10+
</mat-sidenav>
11+
<mat-sidenav-content>
12+
13+
<ng-content select=".content"></ng-content>
14+
15+
</mat-sidenav-content>
16+
</mat-sidenav-container>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
2+
import { CommonModule } from '@angular/common';
3+
import { Component, Inject } from '@angular/core';
4+
import { MatButtonModule } from '@angular/material/button';
5+
import { MatIconModule } from '@angular/material/icon';
6+
import { MatListModule } from '@angular/material/list';
7+
import { MatSidenavModule } from '@angular/material/sidenav';
8+
import { MatToolbarModule } from '@angular/material/toolbar';
9+
import { RouterModule } from '@angular/router';
10+
import { map, shareReplay } from 'rxjs';
11+
12+
@Component({
13+
standalone: true,
14+
selector: 'demo-sidebar-cmp',
15+
imports: [
16+
RouterModule,
17+
CommonModule,
18+
MatToolbarModule,
19+
MatButtonModule,
20+
MatSidenavModule,
21+
MatIconModule,
22+
MatListModule,
23+
],
24+
templateUrl: './sidebar.component.html',
25+
styleUrls: ['./sidebar.component.css']
26+
})
27+
export class SidebarComponent {
28+
isHandset$ = this.breakpointObserver.observe(Breakpoints.Handset)
29+
.pipe(
30+
map(result => result.matches),
31+
shareReplay()
32+
);
33+
34+
constructor(
35+
@Inject(BreakpointObserver) private breakpointObserver: BreakpointObserver) {
36+
}
37+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { FlightService } from '../shared/flight.service';
2+
3+
import {
4+
signalStore, type,
5+
} from '@ngrx/signals';
6+
7+
import { withEntities } from '@ngrx/signals/entities';
8+
import { withCallState, withDataService, withUndoRedo } from 'ngrx-toolkit';
9+
import { Flight } from '../shared/flight';
10+
11+
export const FlightBookingStore = signalStore(
12+
{ providedIn: 'root' },
13+
withCallState({
14+
collection: 'flight'
15+
}),
16+
withEntities({
17+
entity: type<Flight>(),
18+
collection: 'flight'
19+
}),
20+
withDataService({
21+
dataServiceType: FlightService,
22+
filter: { from: 'Paris', to: 'New York' },
23+
collection: 'flight'
24+
}),
25+
withUndoRedo({
26+
collections: ['flight'],
27+
}),
28+
);

0 commit comments

Comments
 (0)