1
0

Added Edit + Create comment funtionality

This commit is contained in:
2020-04-11 20:39:10 -04:00
parent a55c555138
commit c9ec00e984
21 changed files with 330 additions and 46 deletions

View File

@@ -1,40 +1,61 @@
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { BrowserModule } from '@angular/platform-browser';
import { EffectsModule } from '@ngrx/effects';
import { NgModule } from '@angular/core';
import { ReactiveFormsModule, FormsModule } from '@angular/forms';
import { EffectsModule } from '@ngrx/effects';
import { StoreModule } from '@ngrx/store';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatCardModule } from '@angular/material/card';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatChipsModule } from '@angular/material/chips';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatIconModule } from '@angular/material/icon';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatInputModule } from '@angular/material/input';
import { AppComponent } from './app.component';
import { reducers, metaReducers } from './reducers';
import { CommentStoreModule } from './store';
import { PipesModule } from './pipes/pipes.module';
import { DirectivesModule } from './directives/directives.module';
import { CommentCardComponent } from './comment-card/comment-card.component';
import { CommentsListComponent } from './comments-list/comments-list.component';
import { PipesModule } from './pipes/pipes.module';
import { CommentCardEditComponent } from './comment-card-edit/comment-card-edit.component';
const materialModules = [
MatAutocompleteModule,
MatButtonModule,
MatCardModule,
MatChipsModule,
MatFormFieldModule,
MatIconModule,
MatToolbarModule,
MatInputModule
];
@NgModule({
declarations: [
AppComponent,
CommentCardComponent,
CommentsListComponent
CommentsListComponent,
CommentCardEditComponent
],
imports: [
BrowserModule,
BrowserAnimationsModule,
ReactiveFormsModule,
FormsModule,
StoreModule.forRoot(reducers, {
metaReducers
}),
EffectsModule.forRoot([]),
CommentStoreModule,
MatToolbarModule,
MatCardModule,
MatButtonModule,
MatIconModule,
PipesModule
...materialModules,
PipesModule,
DirectivesModule
],
providers: [],
bootstrap: [AppComponent]

View File

@@ -0,0 +1,36 @@
<mat-card class="comment-card">
<form [formGroup]="form" #commentForm="ngForm" (ngSubmit)="submit($event, commentForm)" novalidate>
<mat-form-field class="full-width-field">
<mat-label>Comment Title</mat-label>
<input matInput formControlName="title">
</mat-form-field>
<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="New tag..." #tagInput [formControl]="tagCtrl" [matAutocomplete]="auto"
[matChipInputFor]="chipList" [matChipInputSeparatorKeyCodes]="separatorKeysCodes"
(matChipInputTokenEnd)="addTag($event)">
</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>
<mat-form-field class="full-width-field">
<mat-label>Comment Body</mat-label>
<textarea matInput rows="6" formControlName="text"></textarea>
</mat-form-field>
<mat-card-actions align="end">
<button mat-button (click)="cancel()">
Cancel
</button>
<button mat-button color="primary" type="submit">
{{ !!data?.id ? 'Save' : 'Add' }}
</button>
</mat-card-actions>
</form>
</mat-card>

View File

@@ -0,0 +1,11 @@
:host {
display: block;
}
.comment-card {
max-width: 420px;
}
.full-width-field {
width: 100%;
}

View File

@@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { CommentCardEditComponent } from './comment-card-edit.component';
describe('CommentCardEditComponent', () => {
let component: CommentCardEditComponent;
let fixture: ComponentFixture<CommentCardEditComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ CommentCardEditComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(CommentCardEditComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,105 @@
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { Component, ElementRef, ViewChild, OnInit, Input } from '@angular/core';
import { FormBuilder, Validators, FormGroup } from '@angular/forms';
import { MatAutocompleteSelectedEvent, MatAutocomplete } from '@angular/material/autocomplete';
import { MatChipInputEvent } from '@angular/material/chips';
import { Observable } from 'rxjs';
import { map, startWith, withLatestFrom } from 'rxjs/operators';
import { Comment, CommentFacade } from '../store/comment'
@Component({
selector: 'app-comment-card-edit',
templateUrl: './comment-card-edit.component.html',
styleUrls: ['./comment-card-edit.component.scss']
})
export class CommentCardEditComponent implements OnInit {
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
@ViewChild('auto') matAutocomplete: MatAutocomplete;
@Input() data: Comment;
form: FormGroup;
tagCtrl = this.fb.control([]);
filteredTags: Observable<string[]>;
separatorKeysCodes: number[] = [ENTER, COMMA];
constructor(
private readonly fb: FormBuilder,
private readonly commentFacade: CommentFacade
) { }
ngOnInit(): void {
if (!!this.data) {
this.form = this.fb.group({
id: [this.data.id],
title: [this.data.title, [Validators.required]],
tags: [this.data.tags],
text: [this.data.text, [Validators.required]],
});
} else {
this.form = this.fb.group({
id: [null],
title: [null, [Validators.required]],
tags: [[]],
text: [null, [Validators.required]],
});
}
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));
})
);
}
submit(event, commentForm): void {
event.preventDefault();
if (!!this.form.value.id) {
this.commentFacade.edit(this.form.value);
} else {
this.commentFacade.add(this.form.value);
}
this.form.reset();
commentForm.resetForm();
}
cancel(): void {
this.commentFacade.toggleEdit(undefined);
}
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);
}
addTag(event: MatChipInputEvent): void {
const input = event.input;
const value = event.value;
if ((value || '').trim()) {
const tags = [...this.form.value.tags];
this.form.get('tags').setValue([...tags, value.trim()]);
}
if (input) {
input.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,13 +1,17 @@
<mat-card class="comment-card">
<mat-card-header>
<mat-card-title>{{ data.title }}</mat-card-title>
<mat-card-subtitle>{{ data.tags | join }}</mat-card-subtitle>
<mat-card-subtitle>
<mat-chip-list>
<mat-chip *ngFor="let tag of data.tags" color="primary" selected>{{ tag }}</mat-chip>
</mat-chip-list>
</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<div [innerHTML]="data.text | safeHTML"></div>
</mat-card-content>
<mat-card-actions align="end">
<button mat-icon-button color="primary" (click)="toggleEdit()">
<button mat-button color="primary" (click)="toggleEdit()">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button color="warn" (click)="delete()">

View File

@@ -2,10 +2,6 @@
display: block;
}
:host:not(:first-of-type) {
margin-top: 1rem;
}
.comment-card {
max-width: 420px;
}

View File

@@ -15,7 +15,7 @@ export class CommentCardComponent {
) { }
toggleEdit(): void {
// todo
this.commentFacade.toggleEdit(this.data.id)
}
delete(): void {

View File

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

View File

@@ -0,0 +1,5 @@
.comment-list {
*:not(:first-child) {
margin-top: 1rem;
}
}

View File

@@ -9,6 +9,7 @@ import { CommentFacade } from '../store/comment';
})
export class CommentsListComponent implements OnInit {
list$ = this.commentFacade.list$;
activeEditId$ = this.commentFacade.activeEditId$;
constructor(
private readonly commentFacade: CommentFacade

View File

@@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { NgLetDirective } from './ng-let-directive';
const exportedDirectives = [
NgLetDirective
];
@NgModule({
declarations: [
...exportedDirectives
],
exports: [
...exportedDirectives
]
})
export class DirectivesModule { }

View File

@@ -0,0 +1,34 @@
import {
Directive,
Input,
OnInit,
TemplateRef,
ViewContainerRef
} from '@angular/core';
export class NgLetContext {
$implicit: any = undefined;
ngLet: any = undefined;
}
@Directive({
// tslint:disable-next-line:directive-selector
selector: '[ngLet]'
})
export class NgLetDirective implements OnInit {
private readonly _context = new NgLetContext();
@Input()
set ngLet(value: any) {
this._context.$implicit = this._context.ngLet = value;
}
constructor(
private readonly _vcr: ViewContainerRef,
private readonly _templateRef: TemplateRef<NgLetContext>
) { }
ngOnInit(): void {
this._vcr.createEmbeddedView(this._templateRef, this._context);
}
}

View File

@@ -16,9 +16,14 @@ export const loadCommentsFailure = createAction(
'[Comment/API] Load Comments Failure'
);
export const setCommentEditMode = createAction(
'[Comment] Set Comment Edit Mode',
props<{ id: number }>()
);
export const addComment = createAction(
'[Comment/API] Add Comment',
props<{ comment: Comment }>()
props<{ comment: Partial<Comment> }>()
);
export const upsertComment = createAction(

View File

@@ -15,7 +15,7 @@ export class CommentEffects {
ofType(CommentActions.loadComments),
switchMap(_ =>
this.api.getAll().pipe(
map(data => CommentActions.loadCommentsSuccess({ comments: data as any })), // debug type issue here
map(comments => CommentActions.loadCommentsSuccess({ comments })),
catchError(_ => of(CommentActions.loadCommentsFailure()))
)
)

View File

@@ -1,23 +1,38 @@
import { Injectable } from '@angular/core';
import { Store, select } from '@ngrx/store';
import * as commentActions from './comment.actions';
import * as CommentActions from './comment.actions';
import * as query from './comment.selectors'
import { Comment } from './comment.model';
@Injectable()
export class CommentFacade {
list$ = this.store.pipe(select(query.getAll))
list$ = this.store.pipe(select(query.getAll));
activeEditId$ = this.store.pipe(select(query.getActiveEditId));
tags$ = this.store.pipe(select(query.getAllTags));
constructor(
private readonly store: Store
) { }
getAll(): void {
this.store.dispatch(commentActions.loadComments());
this.store.dispatch(CommentActions.loadComments());
}
toggleEdit(id: number): void {
this.store.dispatch(CommentActions.setCommentEditMode({ id }));
}
add(comment: Partial<Comment>): void {
this.store.dispatch(CommentActions.addComment({ comment }))
}
edit(comment: Comment): void {
this.store.dispatch(CommentActions.updateComment({ comment: { id: comment.id, changes: comment } }));
}
delete(id: number): void {
this.store.dispatch(commentActions.deleteComment({ id }))
this.store.dispatch(CommentActions.deleteComment({ id }))
}
}

View File

@@ -1,13 +0,0 @@
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);
});
});
});

View File

@@ -1,31 +1,44 @@
import { Action, createReducer, on } from '@ngrx/store';
import { 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<Comment> {
// additional entities state properties
activeEditId: number
}
export const adapter: EntityAdapter<Comment> = createEntityAdapter<Comment>();
export const initialState: State = adapter.getInitialState({
// additional entity state properties
activeEditId: undefined
});
export const reducer = createReducer(
initialState,
on(CommentActions.setCommentEditMode,
(state, action) => ({ ...state, activeEditId: action.id })
),
on(CommentActions.addComment,
(state, action) => adapter.addOne(action.comment, state)
(state, action) => {
const newComment = {
...action.comment,
id: Object.keys(state.entities).length + 1
} as Comment;
return adapter.addOne(newComment, state)
}
),
on(CommentActions.upsertComment,
(state, action) => adapter.upsertOne(action.comment, state)
),
on(CommentActions.updateComment,
(state, action) => adapter.updateOne(action.comment, state)
(state, action) => ({
...adapter.updateOne(action.comment, state),
activeEditId: undefined
})
),
on(CommentActions.deleteComment,
(state, action) => adapter.removeOne(action.id, state)
@@ -42,3 +55,4 @@ export const {
selectAll,
selectTotal,
} = adapter.getSelectors();
export const selectActiveEditId = (state: State) => state.activeEditId;

View File

@@ -1,8 +1,9 @@
import { createFeatureSelector, createSelector } from '@ngrx/store';
import { Comment } from './comment.model';
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 getEntities = createSelector(getState, fromComment.selectEntities);
export const getActiveEditId = createSelector(getState, fromComment.selectActiveEditId);
export const getAllTags = createSelector(getAll, comments => Array.from(new Set(comments.map(c => c.tags).flat()).values()));

View File

@@ -3,6 +3,8 @@ import { HttpClient } from '@angular/common/http';
import { map } from 'rxjs/operators'
import { Comment } from './comment.model';
@Injectable()
export class CommentService {

View File

@@ -13,6 +13,7 @@
"target": "es2015",
"lib": [
"es2018",
"ESNext",
"dom"
]
},
@@ -20,4 +21,4 @@
"fullTemplateTypeCheck": true,
"strictInjectionParameters": true
}
}
}