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 { 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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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;">
|
<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>
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -39,4 +39,9 @@ export const updateComment = createAction(
|
|||||||
export const deleteComment = createAction(
|
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[] } }>()
|
||||||
);
|
);
|
||||||
@@ -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 }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()));
|
||||||
@@ -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"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
Reference in New Issue
Block a user