Support for loading, deleting, tag joining, and
This commit is contained in:
@@ -2,4 +2,8 @@
|
||||
<mat-toolbar-row>
|
||||
<span>Material Comments</span>
|
||||
</mat-toolbar-row>
|
||||
</mat-toolbar>
|
||||
</mat-toolbar>
|
||||
|
||||
<div class="app-body">
|
||||
<app-comments-list></app-comments-list>
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
.app-body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -4,22 +4,37 @@ import { EffectsModule } from '@ngrx/effects';
|
||||
import { NgModule } from '@angular/core';
|
||||
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 { reducers, metaReducers } from './reducers';
|
||||
import { CommentStoreModule } from './store';
|
||||
import { CommentCardComponent } from './comment-card/comment-card.component';
|
||||
import { CommentsListComponent } from './comments-list/comments-list.component';
|
||||
import { PipesModule } from './pipes/pipes.module';
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppComponent
|
||||
AppComponent,
|
||||
CommentCardComponent,
|
||||
CommentsListComponent
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
BrowserAnimationsModule,
|
||||
StoreModule.forRoot(reducers, {
|
||||
metaReducers
|
||||
}),
|
||||
EffectsModule.forRoot([]),
|
||||
BrowserAnimationsModule,
|
||||
MatToolbarModule
|
||||
CommentStoreModule,
|
||||
MatToolbarModule,
|
||||
MatCardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
PipesModule
|
||||
],
|
||||
providers: [],
|
||||
bootstrap: [AppComponent]
|
||||
|
||||
17
src/app/comment-card/comment-card.component.html
Normal file
17
src/app/comment-card/comment-card.component.html
Normal 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>
|
||||
11
src/app/comment-card/comment-card.component.scss
Normal file
11
src/app/comment-card/comment-card.component.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
:host:not(:first-of-type) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.comment-card {
|
||||
max-width: 420px;
|
||||
}
|
||||
25
src/app/comment-card/comment-card.component.spec.ts
Normal file
25
src/app/comment-card/comment-card.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
25
src/app/comment-card/comment-card.component.ts
Normal file
25
src/app/comment-card/comment-card.component.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
9
src/app/comments-list/comments-list.component.html
Normal file
9
src/app/comments-list/comments-list.component.html
Normal 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>
|
||||
0
src/app/comments-list/comments-list.component.scss
Normal file
0
src/app/comments-list/comments-list.component.scss
Normal file
25
src/app/comments-list/comments-list.component.spec.ts
Normal file
25
src/app/comments-list/comments-list.component.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
23
src/app/comments-list/comments-list.component.ts
Normal file
23
src/app/comments-list/comments-list.component.ts
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
23
src/app/pipes/join.pipe.spec.ts
Normal file
23
src/app/pipes/join.pipe.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
12
src/app/pipes/join.pipe.ts
Normal file
12
src/app/pipes/join.pipe.ts
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
22
src/app/pipes/pipes.module.ts
Normal file
22
src/app/pipes/pipes.module.ts
Normal 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 { }
|
||||
17
src/app/pipes/safehtml.pipe.ts
Normal file
17
src/app/pipes/safehtml.pipe.ts
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,18 +1,13 @@
|
||||
import {
|
||||
ActionReducer,
|
||||
ActionReducerMap,
|
||||
createFeatureSelector,
|
||||
createSelector,
|
||||
MetaReducer
|
||||
} from '@ngrx/store';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
export interface State {
|
||||
|
||||
}
|
||||
|
||||
export const reducers: ActionReducerMap<State> = {
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
37
src/app/store/comment/comment.actions.ts
Normal file
37
src/app/store/comment/comment.actions.ts
Normal 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 }>()
|
||||
);
|
||||
30
src/app/store/comment/comment.effects.ts
Normal file
30
src/app/store/comment/comment.effects.ts
Normal 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
|
||||
) { }
|
||||
|
||||
}
|
||||
23
src/app/store/comment/comment.facade.ts
Normal file
23
src/app/store/comment/comment.facade.ts
Normal 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 }))
|
||||
}
|
||||
|
||||
}
|
||||
19
src/app/store/comment/comment.model.ts
Normal file
19
src/app/store/comment/comment.model.ts
Normal 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[];
|
||||
}
|
||||
22
src/app/store/comment/comment.module.ts
Normal file
22
src/app/store/comment/comment.module.ts
Normal 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 { }
|
||||
13
src/app/store/comment/comment.reducer.spec.ts
Normal file
13
src/app/store/comment/comment.reducer.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
44
src/app/store/comment/comment.reducer.ts
Normal file
44
src/app/store/comment/comment.reducer.ts
Normal 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();
|
||||
8
src/app/store/comment/comment.selectors.ts
Normal file
8
src/app/store/comment/comment.selectors.ts
Normal 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);
|
||||
16
src/app/store/comment/comment.service.ts
Normal file
16
src/app/store/comment/comment.service.ts
Normal 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) { }
|
||||
}
|
||||
2
src/app/store/comment/index.ts
Normal file
2
src/app/store/comment/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './comment.facade';
|
||||
export * from './comment.model';
|
||||
1
src/app/store/index.ts
Normal file
1
src/app/store/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './comment/comment.module'
|
||||
32
src/assets/data.json
Normal file
32
src/assets/data.json
Normal 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"
|
||||
]
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user