diff --git a/src/app/app.component.html b/src/app/app.component.html index 282cbeb..e49e79c 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -2,4 +2,8 @@ Material Comments - \ No newline at end of file + + +
+ +
\ No newline at end of file diff --git a/src/app/app.component.scss b/src/app/app.component.scss index e69de29..14dea18 100644 --- a/src/app/app.component.scss +++ b/src/app/app.component.scss @@ -0,0 +1,5 @@ +.app-body { + display: flex; + justify-content: center; + margin-top: 1rem; +} \ No newline at end of file diff --git a/src/app/app.module.ts b/src/app/app.module.ts index d5fda4e..6e40470 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -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] diff --git a/src/app/comment-card/comment-card.component.html b/src/app/comment-card/comment-card.component.html new file mode 100644 index 0000000..949de2e --- /dev/null +++ b/src/app/comment-card/comment-card.component.html @@ -0,0 +1,17 @@ + + + {{ data.title }} + {{ data.tags | join }} + + +
+
+ + + + +
\ No newline at end of file diff --git a/src/app/comment-card/comment-card.component.scss b/src/app/comment-card/comment-card.component.scss new file mode 100644 index 0000000..bbc4d0f --- /dev/null +++ b/src/app/comment-card/comment-card.component.scss @@ -0,0 +1,11 @@ +:host { + display: block; +} + +:host:not(:first-of-type) { + margin-top: 1rem; +} + +.comment-card { + max-width: 420px; +} \ No newline at end of file diff --git a/src/app/comment-card/comment-card.component.spec.ts b/src/app/comment-card/comment-card.component.spec.ts new file mode 100644 index 0000000..eb93cf8 --- /dev/null +++ b/src/app/comment-card/comment-card.component.spec.ts @@ -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; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ CommentCardComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CommentCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/comment-card/comment-card.component.ts b/src/app/comment-card/comment-card.component.ts new file mode 100644 index 0000000..0bd8343 --- /dev/null +++ b/src/app/comment-card/comment-card.component.ts @@ -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); + } + +} diff --git a/src/app/comments-list/comments-list.component.html b/src/app/comments-list/comments-list.component.html new file mode 100644 index 0000000..aa11efb --- /dev/null +++ b/src/app/comments-list/comments-list.component.html @@ -0,0 +1,9 @@ +
+ + + +
+ + +

Loading...

+
\ No newline at end of file diff --git a/src/app/comments-list/comments-list.component.scss b/src/app/comments-list/comments-list.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/comments-list/comments-list.component.spec.ts b/src/app/comments-list/comments-list.component.spec.ts new file mode 100644 index 0000000..7d816e5 --- /dev/null +++ b/src/app/comments-list/comments-list.component.spec.ts @@ -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; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ CommentsListComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CommentsListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/comments-list/comments-list.component.ts b/src/app/comments-list/comments-list.component.ts new file mode 100644 index 0000000..764c4a2 --- /dev/null +++ b/src/app/comments-list/comments-list.component.ts @@ -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(); + } + +} diff --git a/src/app/pipes/join.pipe.spec.ts b/src/app/pipes/join.pipe.spec.ts new file mode 100644 index 0000000..1999698 --- /dev/null +++ b/src/app/pipes/join.pipe.spec.ts @@ -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'); + }); +}); \ No newline at end of file diff --git a/src/app/pipes/join.pipe.ts b/src/app/pipes/join.pipe.ts new file mode 100644 index 0000000..430c385 --- /dev/null +++ b/src/app/pipes/join.pipe.ts @@ -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; + } + +} \ No newline at end of file diff --git a/src/app/pipes/pipes.module.ts b/src/app/pipes/pipes.module.ts new file mode 100644 index 0000000..85fd07f --- /dev/null +++ b/src/app/pipes/pipes.module.ts @@ -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 { } diff --git a/src/app/pipes/safehtml.pipe.ts b/src/app/pipes/safehtml.pipe.ts new file mode 100644 index 0000000..f9fc3e5 --- /dev/null +++ b/src/app/pipes/safehtml.pipe.ts @@ -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); + } + +} \ No newline at end of file diff --git a/src/app/reducers/index.ts b/src/app/reducers/index.ts index 4f57bb0..c1c6ed1 100644 --- a/src/app/reducers/index.ts +++ b/src/app/reducers/index.ts @@ -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 = { - }; diff --git a/src/app/store/comment/comment.actions.ts b/src/app/store/comment/comment.actions.ts new file mode 100644 index 0000000..cadb73c --- /dev/null +++ b/src/app/store/comment/comment.actions.ts @@ -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 }>() +); + +export const deleteComment = createAction( + '[Comment/API] Delete Comment', + props<{ id: number }>() +); \ No newline at end of file diff --git a/src/app/store/comment/comment.effects.ts b/src/app/store/comment/comment.effects.ts new file mode 100644 index 0000000..fe173d9 --- /dev/null +++ b/src/app/store/comment/comment.effects.ts @@ -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 + ) { } + +} diff --git a/src/app/store/comment/comment.facade.ts b/src/app/store/comment/comment.facade.ts new file mode 100644 index 0000000..aa732a6 --- /dev/null +++ b/src/app/store/comment/comment.facade.ts @@ -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 })) + } + +} diff --git a/src/app/store/comment/comment.model.ts b/src/app/store/comment/comment.model.ts new file mode 100644 index 0000000..688ba96 --- /dev/null +++ b/src/app/store/comment/comment.model.ts @@ -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 Links”, +tags: [“bug”, “issue”, “etc”] +} + */ + +export interface Comment { + id: number; + // created: Date; + // updated: Date; + title: string; + text: string; + tags: string[]; +} diff --git a/src/app/store/comment/comment.module.ts b/src/app/store/comment/comment.module.ts new file mode 100644 index 0000000..86df8f1 --- /dev/null +++ b/src/app/store/comment/comment.module.ts @@ -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 { } diff --git a/src/app/store/comment/comment.reducer.spec.ts b/src/app/store/comment/comment.reducer.spec.ts new file mode 100644 index 0000000..bc2ef74 --- /dev/null +++ b/src/app/store/comment/comment.reducer.spec.ts @@ -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); + }); + }); +}); diff --git a/src/app/store/comment/comment.reducer.ts b/src/app/store/comment/comment.reducer.ts new file mode 100644 index 0000000..d32ee96 --- /dev/null +++ b/src/app/store/comment/comment.reducer.ts @@ -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 { + // additional entities state properties +} + +export const adapter: EntityAdapter = createEntityAdapter(); + +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(); diff --git a/src/app/store/comment/comment.selectors.ts b/src/app/store/comment/comment.selectors.ts new file mode 100644 index 0000000..de0c16b --- /dev/null +++ b/src/app/store/comment/comment.selectors.ts @@ -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.commentsFeatureKey); +export const getAll = createSelector(getState, fromComment.selectAll); +export const getEntities = createSelector(getState, fromComment.selectEntities); \ No newline at end of file diff --git a/src/app/store/comment/comment.service.ts b/src/app/store/comment/comment.service.ts new file mode 100644 index 0000000..1cd211d --- /dev/null +++ b/src/app/store/comment/comment.service.ts @@ -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) { } +} diff --git a/src/app/store/comment/index.ts b/src/app/store/comment/index.ts new file mode 100644 index 0000000..55cd3dd --- /dev/null +++ b/src/app/store/comment/index.ts @@ -0,0 +1,2 @@ +export * from './comment.facade'; +export * from './comment.model'; \ No newline at end of file diff --git a/src/app/store/index.ts b/src/app/store/index.ts new file mode 100644 index 0000000..b6c3f72 --- /dev/null +++ b/src/app/store/index.ts @@ -0,0 +1 @@ +export * from './comment/comment.module' \ No newline at end of file diff --git a/src/assets/data.json b/src/assets/data.json new file mode 100644 index 0000000..4e9d42b --- /dev/null +++ b/src/assets/data.json @@ -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 Links", + "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 Links", + "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 Links", + "tags": [ + "bug", + "issue", + "etc" + ] + } +] \ No newline at end of file