diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 940a3f5..7887ab6 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -24,6 +24,7 @@ import { DirectivesModule } from './directives/directives.module'; import { CommentCardComponent } from './comment-card/comment-card.component'; import { CommentsListComponent } from './comments-list/comments-list.component'; import { CommentCardEditComponent } from './comment-card-edit/comment-card-edit.component'; +import { CommentFilterComponent } from './comment-filter/comment-filter.component'; const materialModules = [ MatAutocompleteModule, @@ -41,7 +42,8 @@ const materialModules = [ AppComponent, CommentCardComponent, CommentsListComponent, - CommentCardEditComponent + CommentCardEditComponent, + CommentFilterComponent ], imports: [ BrowserModule, diff --git a/src/app/comment-card-edit/comment-card-edit.component.ts b/src/app/comment-card-edit/comment-card-edit.component.ts index 98ebfe9..49008db 100644 --- a/src/app/comment-card-edit/comment-card-edit.component.ts +++ b/src/app/comment-card-edit/comment-card-edit.component.ts @@ -79,7 +79,6 @@ export class CommentCardEditComponent implements OnInit { this.tagCtrl.setValue(null); } - addTag(event: MatChipInputEvent): void { const input = event.input; const value = event.value; diff --git a/src/app/comment-filter/comment-filter.component.html b/src/app/comment-filter/comment-filter.component.html new file mode 100644 index 0000000..33fafed --- /dev/null +++ b/src/app/comment-filter/comment-filter.component.html @@ -0,0 +1,15 @@ + + + + {{ tag }} + cancel + + + + + + {{ tag }} + + + \ No newline at end of file diff --git a/src/app/comment-filter/comment-filter.component.scss b/src/app/comment-filter/comment-filter.component.scss new file mode 100644 index 0000000..924a7a1 --- /dev/null +++ b/src/app/comment-filter/comment-filter.component.scss @@ -0,0 +1,3 @@ +.full-width-field { + width: 100%; +} \ No newline at end of file diff --git a/src/app/comment-filter/comment-filter.component.spec.ts b/src/app/comment-filter/comment-filter.component.spec.ts new file mode 100644 index 0000000..7575764 --- /dev/null +++ b/src/app/comment-filter/comment-filter.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CommentFilterComponent } from './comment-filter.component'; + +describe('CommentFilterComponent', () => { + let component: CommentFilterComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ CommentFilterComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(CommentFilterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/comment-filter/comment-filter.component.ts b/src/app/comment-filter/comment-filter.component.ts new file mode 100644 index 0000000..6abcdf5 --- /dev/null +++ b/src/app/comment-filter/comment-filter.component.ts @@ -0,0 +1,69 @@ +import { COMMA, ENTER } from '@angular/cdk/keycodes'; +import { Component, ElementRef, ViewChild, OnInit, OnDestroy } from '@angular/core'; +import { FormBuilder } from '@angular/forms'; +import { MatAutocompleteSelectedEvent, MatAutocomplete } from '@angular/material/autocomplete'; + +import { Observable } from 'rxjs'; +import { startWith, withLatestFrom, map } from 'rxjs/operators'; +import { untilDestroy } from '../operators'; + +import { CommentFacade } from '../store/comment'; + +@Component({ + selector: 'app-comment-filter', + templateUrl: './comment-filter.component.html', + styleUrls: ['./comment-filter.component.scss'] +}) +export class CommentFilterComponent implements OnInit, OnDestroy { + @ViewChild('tagInput') tagInput: ElementRef; + @ViewChild('auto') matAutocomplete: MatAutocomplete; + form = this.fb.group({ + tags: [[]] + }); + tagCtrl = this.fb.control([]); + filteredTags: Observable; + separatorKeysCodes: number[] = [ENTER, COMMA]; + + constructor( + private readonly fb: FormBuilder, + private readonly commentFacade: CommentFacade + ) { } + + ngOnInit(): void { + this.filteredTags = this.tagCtrl.valueChanges.pipe( + startWith(null), + withLatestFrom(this.commentFacade.tags$), + map(([tag, allTags]) => { + if (tag === null) { + return [...allTags]; + } + const value = tag.toLowerCase() + return allTags.filter(t => t.toLowerCase().includes(value)); + }) + ); + + this.form.valueChanges.pipe( + untilDestroy(this) + ).subscribe(formValue => { + this.commentFacade.filter(formValue) + }); + } + + ngOnDestroy(): void { + // stub for untilDestroy + } + + selected(event: MatAutocompleteSelectedEvent): void { + const tags = [...this.form.value.tags]; + this.form.get('tags').setValue([...tags, event.option.viewValue]); + this.tagInput.nativeElement.value = ''; + this.tagCtrl.setValue(null); + } + + removeTag(tag: string): void { + const tags = [...this.form.value.tags]; + + this.form.get('tags').setValue([...tags.filter(t => t !== tag)]); + } + +} diff --git a/src/app/comments-list/comments-list.component.html b/src/app/comments-list/comments-list.component.html index 245879d..21825ae 100644 --- a/src/app/comments-list/comments-list.component.html +++ b/src/app/comments-list/comments-list.component.html @@ -1,4 +1,5 @@
+ diff --git a/src/app/operators/index.ts b/src/app/operators/index.ts new file mode 100644 index 0000000..41660e7 --- /dev/null +++ b/src/app/operators/index.ts @@ -0,0 +1 @@ +export * from './until-destroy.operator'; \ No newline at end of file diff --git a/src/app/operators/until-destroy.operator.ts b/src/app/operators/until-destroy.operator.ts new file mode 100644 index 0000000..14a6daf --- /dev/null +++ b/src/app/operators/until-destroy.operator.ts @@ -0,0 +1,51 @@ +import { MonoTypeOperatorFunction, Observable } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +/** + * create a symbol identify the observable I add to + * the component so it doesn't conflict with anything. + * I need this so I'm able to add the desired behavior to the component. + */ +export const destroy$ = Symbol('destroy$'); + +/** + * @param component - "this", component reference + * An operator that takes the component as a property + * @returns - .pipe()-able RxJS operator + */ +export const untilDestroy = (component: any): MonoTypeOperatorFunction => { + if (component[destroy$] === undefined) { + // only hookup each component once. + addDestroyObservableToComponent(component); + } + + // pipe in the takeUntil destroy$ and return the source unaltered + return takeUntil(component[destroy$]); +}; + +/** + * @internal + */ +export const addDestroyObservableToComponent = (component: any): any => { + component[destroy$] = new Observable(observer => { + // keep track of the original destroy function, + // the user might do something in there + const originalDestroy = component.ngOnDestroy; + if (typeof originalDestroy === 'undefined') { + // angular (AOT) does not support dynamic added destroy methods + // so make sure there is one. + throw new Error('untilDestroy operator needs the component to have an ngOnDestroy method'); + } + // replace the ngOnDestroy + component.ngOnDestroy = () => { + // fire off the destroy observable + observer.next(); + // complete the observable + observer.complete(); + // and at last, call the original destroy + originalDestroy.call(component); + }; + // return cleanup function. + return (_: any) => (component[destroy$] = undefined); + }); +}; \ No newline at end of file diff --git a/src/app/store/comment/comment.actions.ts b/src/app/store/comment/comment.actions.ts index d0a05bd..6a86ff9 100644 --- a/src/app/store/comment/comment.actions.ts +++ b/src/app/store/comment/comment.actions.ts @@ -39,4 +39,9 @@ export const updateComment = createAction( export const deleteComment = createAction( '[Comment/API] Delete Comment', props<{ id: number }>() +); + +export const filterComments = createAction( + '[Comment] Filter Comments', + props<{ filters: { tags: string[] } }>() ); \ No newline at end of file diff --git a/src/app/store/comment/comment.facade.ts b/src/app/store/comment/comment.facade.ts index 6870b07..b35de25 100644 --- a/src/app/store/comment/comment.facade.ts +++ b/src/app/store/comment/comment.facade.ts @@ -7,7 +7,7 @@ import { Comment } from './comment.model'; @Injectable() export class CommentFacade { - list$ = this.store.pipe(select(query.getAll)); + list$ = this.store.pipe(select(query.getAllFiltered)); activeEditId$ = this.store.pipe(select(query.getActiveEditId)); tags$ = this.store.pipe(select(query.getAllTags)); @@ -19,6 +19,10 @@ export class CommentFacade { this.store.dispatch(CommentActions.loadComments()); } + filter(filters): void { + this.store.dispatch(CommentActions.filterComments({ filters })) + } + toggleEdit(id: number): void { this.store.dispatch(CommentActions.setCommentEditMode({ id })); } diff --git a/src/app/store/comment/comment.reducer.ts b/src/app/store/comment/comment.reducer.ts index 20c75a4..4066cfb 100644 --- a/src/app/store/comment/comment.reducer.ts +++ b/src/app/store/comment/comment.reducer.ts @@ -7,21 +7,32 @@ import * as CommentActions from './comment.actions'; export const commentsFeatureKey = 'comments'; export interface State extends EntityState { - activeEditId: number + activeEditId: number, + filters: { + tags: string[] + } } export const adapter: EntityAdapter = createEntityAdapter(); export const initialState: State = adapter.getInitialState({ - activeEditId: undefined + activeEditId: undefined, + filters: { + tags: [] + } }); - export const reducer = createReducer( initialState, on(CommentActions.setCommentEditMode, (state, action) => ({ ...state, activeEditId: action.id }) ), + on(CommentActions.filterComments, + (state, action) => ({ + ...state, + filters: action.filters + }) + ), on(CommentActions.addComment, (state, action) => { const newComment = { @@ -56,3 +67,4 @@ export const { selectTotal, } = adapter.getSelectors(); export const selectActiveEditId = (state: State) => state.activeEditId; +export const selectFilters = (state: State) => state.filters; diff --git a/src/app/store/comment/comment.selectors.ts b/src/app/store/comment/comment.selectors.ts index eb353b7..b0b3344 100644 --- a/src/app/store/comment/comment.selectors.ts +++ b/src/app/store/comment/comment.selectors.ts @@ -5,5 +5,18 @@ 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); +export const getFilters = createSelector(getState, fromComment.selectFilters); +export const getAllFiltered = createSelector(getAll, getFilters, (comments, filters) => { + if (!filters) { + return comments; + } + const { tags } = filters; + if (tags.length === 0) { + return comments + } + return comments.filter(c => { + return c.tags.findIndex(t => tags.includes(t)) !== -1 + }) +}) export const getActiveEditId = createSelector(getState, fromComment.selectActiveEditId); export const getAllTags = createSelector(getAll, comments => Array.from(new Set(comments.map(c => c.tags).flat()).values())); \ No newline at end of file diff --git a/src/assets/data.json b/src/assets/data.json index 4e9d42b..898184e 100644 --- a/src/assets/data.json +++ b/src/assets/data.json @@ -4,9 +4,7 @@ "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" + "bug" ] }, { @@ -14,9 +12,7 @@ "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" + "bug" ] }, { @@ -24,9 +20,7 @@ "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" + "feature" ] } ] \ No newline at end of file