1
0

Adding filter support

This commit is contained in:
2020-04-11 21:17:35 -04:00
parent c9ec00e984
commit 5ad189a83e
14 changed files with 209 additions and 15 deletions

View File

@@ -24,6 +24,7 @@ import { DirectivesModule } from './directives/directives.module';
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 { CommentsListComponent } from './comments-list/comments-list.component';
import { CommentCardEditComponent } from './comment-card-edit/comment-card-edit.component'; import { CommentCardEditComponent } from './comment-card-edit/comment-card-edit.component';
import { CommentFilterComponent } from './comment-filter/comment-filter.component';
const materialModules = [ const materialModules = [
MatAutocompleteModule, MatAutocompleteModule,
@@ -41,7 +42,8 @@ const materialModules = [
AppComponent, AppComponent,
CommentCardComponent, CommentCardComponent,
CommentsListComponent, CommentsListComponent,
CommentCardEditComponent CommentCardEditComponent,
CommentFilterComponent
], ],
imports: [ imports: [
BrowserModule, BrowserModule,

View File

@@ -79,7 +79,6 @@ export class CommentCardEditComponent implements OnInit {
this.tagCtrl.setValue(null); this.tagCtrl.setValue(null);
} }
addTag(event: MatChipInputEvent): void { addTag(event: MatChipInputEvent): void {
const input = event.input; const input = event.input;
const value = event.value; const value = event.value;

View 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>

View File

@@ -0,0 +1,3 @@
.full-width-field {
width: 100%;
}

View 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();
});
});

View 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)]);
}
}

View File

@@ -1,4 +1,5 @@
<section class="comment-list" *ngIf="list$ | async as list; else loading;"> <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 *ngLet="activeEditId$ | async as activeEditId">
<ng-container *ngFor="let item of list"> <ng-container *ngFor="let item of list">
<app-comment-card *ngIf="activeEditId !== item.id" [data]="item"></app-comment-card> <app-comment-card *ngIf="activeEditId !== item.id" [data]="item"></app-comment-card>

View File

@@ -0,0 +1 @@
export * from './until-destroy.operator';

View 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);
});
};

View File

@@ -40,3 +40,8 @@ export const deleteComment = createAction(
'[Comment/API] Delete Comment', '[Comment/API] Delete Comment',
props<{ id: number }>() props<{ id: number }>()
); );
export const filterComments = createAction(
'[Comment] Filter Comments',
props<{ filters: { tags: string[] } }>()
);

View File

@@ -7,7 +7,7 @@ import { Comment } from './comment.model';
@Injectable() @Injectable()
export class CommentFacade { export class CommentFacade {
list$ = this.store.pipe(select(query.getAll)); list$ = this.store.pipe(select(query.getAllFiltered));
activeEditId$ = this.store.pipe(select(query.getActiveEditId)); activeEditId$ = this.store.pipe(select(query.getActiveEditId));
tags$ = this.store.pipe(select(query.getAllTags)); tags$ = this.store.pipe(select(query.getAllTags));
@@ -19,6 +19,10 @@ export class CommentFacade {
this.store.dispatch(CommentActions.loadComments()); this.store.dispatch(CommentActions.loadComments());
} }
filter(filters): void {
this.store.dispatch(CommentActions.filterComments({ filters }))
}
toggleEdit(id: number): void { toggleEdit(id: number): void {
this.store.dispatch(CommentActions.setCommentEditMode({ id })); this.store.dispatch(CommentActions.setCommentEditMode({ id }));
} }

View File

@@ -7,21 +7,32 @@ import * as CommentActions from './comment.actions';
export const commentsFeatureKey = 'comments'; export const commentsFeatureKey = 'comments';
export interface State extends EntityState<Comment> { export interface State extends EntityState<Comment> {
activeEditId: number activeEditId: number,
filters: {
tags: string[]
}
} }
export const adapter: EntityAdapter<Comment> = createEntityAdapter<Comment>(); export const adapter: EntityAdapter<Comment> = createEntityAdapter<Comment>();
export const initialState: State = adapter.getInitialState({ export const initialState: State = adapter.getInitialState({
activeEditId: undefined activeEditId: undefined,
filters: {
tags: []
}
}); });
export const reducer = createReducer( export const reducer = createReducer(
initialState, initialState,
on(CommentActions.setCommentEditMode, on(CommentActions.setCommentEditMode,
(state, action) => ({ ...state, activeEditId: action.id }) (state, action) => ({ ...state, activeEditId: action.id })
), ),
on(CommentActions.filterComments,
(state, action) => ({
...state,
filters: action.filters
})
),
on(CommentActions.addComment, on(CommentActions.addComment,
(state, action) => { (state, action) => {
const newComment = { const newComment = {
@@ -56,3 +67,4 @@ export const {
selectTotal, selectTotal,
} = adapter.getSelectors(); } = adapter.getSelectors();
export const selectActiveEditId = (state: State) => state.activeEditId; export const selectActiveEditId = (state: State) => state.activeEditId;
export const selectFilters = (state: State) => state.filters;

View File

@@ -5,5 +5,18 @@ import * as fromComment from './comment.reducer';
export const getState = createFeatureSelector<fromComment.State>(fromComment.commentsFeatureKey); export const getState = createFeatureSelector<fromComment.State>(fromComment.commentsFeatureKey);
export const getAll = createSelector(getState, fromComment.selectAll); export const getAll = createSelector(getState, fromComment.selectAll);
export const getEntities = createSelector(getState, fromComment.selectEntities); 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 getActiveEditId = createSelector(getState, fromComment.selectActiveEditId);
export const getAllTags = createSelector(getAll, comments => Array.from(new Set(comments.map(c => c.tags).flat()).values())); export const getAllTags = createSelector(getAll, comments => Array.from(new Set(comments.map(c => c.tags).flat()).values()));

View File

@@ -4,9 +4,7 @@
"title": "Comment One", "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>", "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": [ "tags": [
"bug", "bug"
"issue",
"etc"
] ]
}, },
{ {
@@ -14,9 +12,7 @@
"title": "Comment Two", "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>", "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": [ "tags": [
"bug", "bug"
"issue",
"etc"
] ]
}, },
{ {
@@ -24,9 +20,7 @@
"title": "Comment Three", "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>", "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": [ "tags": [
"bug", "feature"
"issue",
"etc"
] ]
} }
] ]