Adding filter support
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
15
src/app/comment-filter/comment-filter.component.html
Normal file
15
src/app/comment-filter/comment-filter.component.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<mat-form-field class="full-width-field">
|
||||
<mat-chip-list #chipList>
|
||||
<mat-chip *ngFor="let tag of form.value.tags" selectable="true" removable="true" (removed)="removeTag(tag)">
|
||||
{{ tag }}
|
||||
<mat-icon matChipRemove>cancel</mat-icon>
|
||||
</mat-chip>
|
||||
<input placeholder="Filter by tag..." #tagInput [formControl]="tagCtrl" [matAutocomplete]="auto"
|
||||
[matChipInputFor]="chipList" [matChipInputSeparatorKeyCodes]="separatorKeysCodes">
|
||||
</mat-chip-list>
|
||||
<mat-autocomplete #auto="matAutocomplete" (optionSelected)="selected($event)">
|
||||
<mat-option *ngFor="let tag of filteredTags | async" [value]="tag">
|
||||
{{ tag }}
|
||||
</mat-option>
|
||||
</mat-autocomplete>
|
||||
</mat-form-field>
|
||||
3
src/app/comment-filter/comment-filter.component.scss
Normal file
3
src/app/comment-filter/comment-filter.component.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.full-width-field {
|
||||
width: 100%;
|
||||
}
|
||||
25
src/app/comment-filter/comment-filter.component.spec.ts
Normal file
25
src/app/comment-filter/comment-filter.component.spec.ts
Normal file
@@ -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<CommentFilterComponent>;
|
||||
|
||||
beforeEach(async(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ CommentFilterComponent ]
|
||||
})
|
||||
.compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(CommentFilterComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
69
src/app/comment-filter/comment-filter.component.ts
Normal file
69
src/app/comment-filter/comment-filter.component.ts
Normal file
@@ -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<HTMLInputElement>;
|
||||
@ViewChild('auto') matAutocomplete: MatAutocomplete;
|
||||
form = this.fb.group({
|
||||
tags: [[]]
|
||||
});
|
||||
tagCtrl = this.fb.control([]);
|
||||
filteredTags: Observable<string[]>;
|
||||
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)]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<section class="comment-list" *ngIf="list$ | async as list; else loading;">
|
||||
<app-comment-filter></app-comment-filter>
|
||||
<ng-container *ngLet="activeEditId$ | async as activeEditId">
|
||||
<ng-container *ngFor="let item of list">
|
||||
<app-comment-card *ngIf="activeEditId !== item.id" [data]="item"></app-comment-card>
|
||||
|
||||
1
src/app/operators/index.ts
Normal file
1
src/app/operators/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './until-destroy.operator';
|
||||
51
src/app/operators/until-destroy.operator.ts
Normal file
51
src/app/operators/until-destroy.operator.ts
Normal file
@@ -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 = <T>(component: any): MonoTypeOperatorFunction<T> => {
|
||||
if (component[destroy$] === undefined) {
|
||||
// only hookup each component once.
|
||||
addDestroyObservableToComponent(component);
|
||||
}
|
||||
|
||||
// pipe in the takeUntil destroy$ and return the source unaltered
|
||||
return takeUntil<T>(component[destroy$]);
|
||||
};
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export const addDestroyObservableToComponent = (component: any): any => {
|
||||
component[destroy$] = new Observable<void>(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);
|
||||
});
|
||||
};
|
||||
@@ -40,3 +40,8 @@ export const deleteComment = createAction(
|
||||
'[Comment/API] Delete Comment',
|
||||
props<{ id: number }>()
|
||||
);
|
||||
|
||||
export const filterComments = createAction(
|
||||
'[Comment] Filter Comments',
|
||||
props<{ filters: { tags: string[] } }>()
|
||||
);
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -7,21 +7,32 @@ import * as CommentActions from './comment.actions';
|
||||
export const commentsFeatureKey = 'comments';
|
||||
|
||||
export interface State extends EntityState<Comment> {
|
||||
activeEditId: number
|
||||
activeEditId: number,
|
||||
filters: {
|
||||
tags: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export const adapter: EntityAdapter<Comment> = createEntityAdapter<Comment>();
|
||||
|
||||
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;
|
||||
|
||||
@@ -5,5 +5,18 @@ 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);
|
||||
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()));
|
||||
@@ -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 <a href=\"http://www.google.com\">Links</a>",
|
||||
"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 <a href=\"http://www.google.com\">Links</a>",
|
||||
"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 <a href=\"http://www.google.com\">Links</a>",
|
||||
"tags": [
|
||||
"bug",
|
||||
"issue",
|
||||
"etc"
|
||||
"feature"
|
||||
]
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user