1
0

Support for loading, deleting, tag joining, and

This commit is contained in:
2020-04-10 17:40:02 -04:00
parent ebae5a5801
commit a55c555138
28 changed files with 484 additions and 9 deletions

View File

@@ -2,4 +2,8 @@
<mat-toolbar-row> <mat-toolbar-row>
<span>Material Comments</span> <span>Material Comments</span>
</mat-toolbar-row> </mat-toolbar-row>
</mat-toolbar> </mat-toolbar>
<div class="app-body">
<app-comments-list></app-comments-list>
</div>

View File

@@ -0,0 +1,5 @@
.app-body {
display: flex;
justify-content: center;
margin-top: 1rem;
}

View File

@@ -4,22 +4,37 @@ import { EffectsModule } from '@ngrx/effects';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { StoreModule } from '@ngrx/store'; import { StoreModule } from '@ngrx/store';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { AppComponent } from './app.component'; import { AppComponent } from './app.component';
import { reducers, metaReducers } from './reducers'; import { reducers, metaReducers } from './reducers';
import { CommentStoreModule } from './store';
import { CommentCardComponent } from './comment-card/comment-card.component'; import { CommentCardComponent } from './comment-card/comment-card.component';
import { CommentsListComponent } from './comments-list/comments-list.component';
import { PipesModule } from './pipes/pipes.module';
@NgModule({ @NgModule({
declarations: [ declarations: [
AppComponent AppComponent,
CommentCardComponent,
CommentsListComponent
], ],
imports: [ imports: [
BrowserModule, BrowserModule,
BrowserAnimationsModule,
StoreModule.forRoot(reducers, { StoreModule.forRoot(reducers, {
metaReducers metaReducers
}), }),
EffectsModule.forRoot([]), EffectsModule.forRoot([]),
BrowserAnimationsModule, CommentStoreModule,
MatToolbarModule MatToolbarModule,
MatCardModule,
MatButtonModule,
MatIconModule,
PipesModule
], ],
providers: [], providers: [],
bootstrap: [AppComponent] bootstrap: [AppComponent]

View File

@@ -0,0 +1,17 @@
<mat-card class="comment-card">
<mat-card-header>
<mat-card-title>{{ data.title }}</mat-card-title>
<mat-card-subtitle>{{ data.tags | join }}</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<div [innerHTML]="data.text | safeHTML"></div>
</mat-card-content>
<mat-card-actions align="end">
<button mat-icon-button color="primary" (click)="toggleEdit()">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button color="warn" (click)="delete()">
<mat-icon>delete</mat-icon>
</button>
</mat-card-actions>
</mat-card>

View File

@@ -0,0 +1,11 @@
:host {
display: block;
}
:host:not(:first-of-type) {
margin-top: 1rem;
}
.comment-card {
max-width: 420px;
}

View File

@@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CommentCardComponent } from './comment-card.component';
describe('CommentCardComponent', () => {
let component: CommentCardComponent;
let fixture: ComponentFixture<CommentCardComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ CommentCardComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CommentCardComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,25 @@
import { Component, Input } from '@angular/core';
import { CommentFacade, Comment } from '../store/comment';
@Component({
selector: 'app-comment-card',
templateUrl: './comment-card.component.html',
styleUrls: ['./comment-card.component.scss']
})
export class CommentCardComponent {
@Input() data: Comment;
constructor(
private readonly commentFacade: CommentFacade
) { }
toggleEdit(): void {
// todo
}
delete(): void {
this.commentFacade.delete(this.data.id);
}
}

View File

@@ -0,0 +1,9 @@
<section class="comment-list" *ngIf="list$ | async as list; else loading;">
<ng-container *ngFor="let item of list">
<app-comment-card [data]="item"></app-comment-card>
</ng-container>
</section>
<ng-template #loading>
<p>Loading...</p>
</ng-template>

View File

@@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CommentsListComponent } from './comments-list.component';
describe('CommentsListComponent', () => {
let component: CommentsListComponent;
let fixture: ComponentFixture<CommentsListComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ CommentsListComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CommentsListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,23 @@
import { Component, OnInit } from '@angular/core';
import { CommentFacade } from '../store/comment';
@Component({
selector: 'app-comments-list',
templateUrl: './comments-list.component.html',
styleUrls: ['./comments-list.component.scss']
})
export class CommentsListComponent implements OnInit {
list$ = this.commentFacade.list$;
constructor(
private readonly commentFacade: CommentFacade
) { }
ngOnInit(): void {
// load comments, typically would do this with an effect watching the route,
// but for simplicity I'm just calling it here.
this.commentFacade.getAll();
}
}

View File

@@ -0,0 +1,23 @@
import { JoinPipe } from './join.pipe';
describe('JoinPipe', () => {
let pipe: JoinPipe;
beforeEach(() => {
pipe = new JoinPipe();
});
it('create an instance', () => {
expect(pipe).toBeTruthy();
});
it('should transform array into string with \',\' separator', () => {
const array = ['one', 'two', 'three', 'four'];
expect(pipe.transform(array)).toBe('one, two, three, four');
});
it('should transform array into string with \'::\' separator', () => {
const array = ['one', 'two', 'three', 'four'];
expect(pipe.transform(array, '::')).toBe('one::two::three::four');
});
});

View File

@@ -0,0 +1,12 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'join'
})
export class JoinPipe implements PipeTransform {
transform(value: any[], separator = ', '): any {
return Array.isArray(value) ? value.join(separator) : value;
}
}

View File

@@ -0,0 +1,22 @@
import { NgModule } from '@angular/core';
import { JoinPipe } from './join.pipe';
import { SafeHTMLPipe } from './safehtml.pipe';
const exportedPipes = [
JoinPipe,
SafeHTMLPipe
];
@NgModule({
declarations: [
...exportedPipes,
],
providers: [
...exportedPipes
],
exports: [
...exportedPipes
]
})
export class PipesModule { }

View File

@@ -0,0 +1,17 @@
import { Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@Pipe({
name: 'safeHTML'
})
export class SafeHTMLPipe implements PipeTransform {
constructor(
private readonly _sanitizer: DomSanitizer
) { }
transform(value: string): SafeHtml {
return this._sanitizer.bypassSecurityTrustHtml(value);
}
}

View File

@@ -1,18 +1,13 @@
import { import {
ActionReducer,
ActionReducerMap, ActionReducerMap,
createFeatureSelector,
createSelector,
MetaReducer MetaReducer
} from '@ngrx/store'; } from '@ngrx/store';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
export interface State { export interface State {
} }
export const reducers: ActionReducerMap<State> = { export const reducers: ActionReducerMap<State> = {
}; };

View File

@@ -0,0 +1,37 @@
import { createAction, props } from '@ngrx/store';
import { Update } from '@ngrx/entity';
import { Comment } from './comment.model';
export const loadComments = createAction(
'[Comment/API] Load Comments'
);
export const loadCommentsSuccess = createAction(
'[Comment/API] Load Comments Success',
props<{ comments: Comment[] }>()
);
export const loadCommentsFailure = createAction(
'[Comment/API] Load Comments Failure'
);
export const addComment = createAction(
'[Comment/API] Add Comment',
props<{ comment: Comment }>()
);
export const upsertComment = createAction(
'[Comment/API] Upsert Comment',
props<{ comment: Comment }>()
);
export const updateComment = createAction(
'[Comment/API] Update Comment',
props<{ comment: Update<Comment> }>()
);
export const deleteComment = createAction(
'[Comment/API] Delete Comment',
props<{ id: number }>()
);

View File

@@ -0,0 +1,30 @@
import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { of } from 'rxjs';
import { map, switchMap, catchError } from 'rxjs/operators';
import { CommentService } from './comment.service';
import * as CommentActions from './comment.actions';
@Injectable()
export class CommentEffects {
loadComments$ = createEffect(() =>
this.actions$.pipe(
ofType(CommentActions.loadComments),
switchMap(_ =>
this.api.getAll().pipe(
map(data => CommentActions.loadCommentsSuccess({ comments: data as any })), // debug type issue here
catchError(_ => of(CommentActions.loadCommentsFailure()))
)
)
)
);
constructor(
private api: CommentService,
private actions$: Actions
) { }
}

View File

@@ -0,0 +1,23 @@
import { Injectable } from '@angular/core';
import { Store, select } from '@ngrx/store';
import * as commentActions from './comment.actions';
import * as query from './comment.selectors'
@Injectable()
export class CommentFacade {
list$ = this.store.pipe(select(query.getAll))
constructor(
private readonly store: Store
) { }
getAll(): void {
this.store.dispatch(commentActions.loadComments());
}
delete(id: number): void {
this.store.dispatch(commentActions.deleteComment({ id }))
}
}

View File

@@ -0,0 +1,19 @@
/**
* from spec
{
id: “1”,
title: “This is an item”,
text: “This is a description of the item, it might describe a bug/task/comment, it
can also display <a href=”www.google.com”>Links</a>”,
tags: [“bug”, “issue”, “etc”]
}
*/
export interface Comment {
id: number;
// created: Date;
// updated: Date;
title: string;
text: string;
tags: string[];
}

View File

@@ -0,0 +1,22 @@
import { NgModule } from '@angular/core';
import { HttpClientModule } from '@angular/common/http';
import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { commentsFeatureKey, reducer } from './comment.reducer';
import { CommentService } from './comment.service';
import { CommentEffects } from './comment.effects';
import { CommentFacade } from './comment.facade';
@NgModule({
imports: [
HttpClientModule,
StoreModule.forFeature(commentsFeatureKey, reducer),
EffectsModule.forFeature([CommentEffects])
],
providers: [
CommentService,
CommentFacade
]
})
export class CommentStoreModule { }

View File

@@ -0,0 +1,13 @@
import { reducer, initialState } from './comment.reducer';
describe('Comment Reducer', () => {
describe('unknown action', () => {
it('should return the previous state', () => {
const action = {} as any;
const result = reducer(initialState, action);
expect(result).toBe(initialState);
});
});
});

View File

@@ -0,0 +1,44 @@
import { Action, createReducer, on } from '@ngrx/store';
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
import { Comment } from './comment.model';
import * as CommentActions from './comment.actions';
export const commentsFeatureKey = 'comments';
export interface State extends EntityState<Comment> {
// additional entities state properties
}
export const adapter: EntityAdapter<Comment> = createEntityAdapter<Comment>();
export const initialState: State = adapter.getInitialState({
// additional entity state properties
});
export const reducer = createReducer(
initialState,
on(CommentActions.addComment,
(state, action) => adapter.addOne(action.comment, state)
),
on(CommentActions.upsertComment,
(state, action) => adapter.upsertOne(action.comment, state)
),
on(CommentActions.updateComment,
(state, action) => adapter.updateOne(action.comment, state)
),
on(CommentActions.deleteComment,
(state, action) => adapter.removeOne(action.id, state)
),
on(CommentActions.loadCommentsSuccess,
(state, action) => adapter.setAll(action.comments, state)
),
);
export const {
selectIds,
selectEntities,
selectAll,
selectTotal,
} = adapter.getSelectors();

View File

@@ -0,0 +1,8 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { Comment } from './comment.model';
import * as fromComment from './comment.reducer';
export const getState = createFeatureSelector<fromComment.State>(fromComment.commentsFeatureKey);
export const getAll = createSelector(getState, fromComment.selectAll);
export const getEntities = createSelector(getState, fromComment.selectEntities);

View File

@@ -0,0 +1,16 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { map } from 'rxjs/operators'
@Injectable()
export class CommentService {
getAll() {
return this.http.get('./assets/data.json').pipe(
map(response => response as Comment[])
)
}
constructor(private http: HttpClient) { }
}

View File

@@ -0,0 +1,2 @@
export * from './comment.facade';
export * from './comment.model';

1
src/app/store/index.ts Normal file
View File

@@ -0,0 +1 @@
export * from './comment/comment.module'

32
src/assets/data.json Normal file
View File

@@ -0,0 +1,32 @@
[
{
"id": 1,
"title": "Comment One",
"text": "This is a description of the item, it might describe a bug/task/comment, it can also display <a href=\"http://www.google.com\">Links</a>",
"tags": [
"bug",
"issue",
"etc"
]
},
{
"id": 2,
"title": "Comment Two",
"text": "This is a description of the item, it might describe a bug/task/comment, it can also display <a href=\"http://www.google.com\">Links</a>",
"tags": [
"bug",
"issue",
"etc"
]
},
{
"id": 3,
"title": "Comment Three",
"text": "This is a description of the item, it might describe a bug/task/comment, it can also display <a href=\"http://www.google.com\">Links</a>",
"tags": [
"bug",
"issue",
"etc"
]
}
]