Added Edit + Create comment funtionality
This commit is contained in:
@@ -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]
|
||||
|
||||
36
src/app/comment-card-edit/comment-card-edit.component.html
Normal file
36
src/app/comment-card-edit/comment-card-edit.component.html
Normal 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>
|
||||
11
src/app/comment-card-edit/comment-card-edit.component.scss
Normal file
11
src/app/comment-card-edit/comment-card-edit.component.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.comment-card {
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.full-width-field {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
105
src/app/comment-card-edit/comment-card-edit.component.ts
Normal file
105
src/app/comment-card-edit/comment-card-edit.component.ts
Normal 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)]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()">
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
:host:not(:first-of-type) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.comment-card {
|
||||
max-width: 420px;
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export class CommentCardComponent {
|
||||
) { }
|
||||
|
||||
toggleEdit(): void {
|
||||
// todo
|
||||
this.commentFacade.toggleEdit(this.data.id)
|
||||
}
|
||||
|
||||
delete(): void {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.comment-list {
|
||||
*:not(:first-child) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
17
src/app/directives/directives.module.ts
Normal file
17
src/app/directives/directives.module.ts
Normal 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 { }
|
||||
34
src/app/directives/ng-let-directive.ts
Normal file
34
src/app/directives/ng-let-directive.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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()))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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()));
|
||||
@@ -3,6 +3,8 @@ import { HttpClient } from '@angular/common/http';
|
||||
|
||||
import { map } from 'rxjs/operators'
|
||||
|
||||
import { Comment } from './comment.model';
|
||||
|
||||
@Injectable()
|
||||
export class CommentService {
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"target": "es2015",
|
||||
"lib": [
|
||||
"es2018",
|
||||
"ESNext",
|
||||
"dom"
|
||||
]
|
||||
},
|
||||
@@ -20,4 +21,4 @@
|
||||
"fullTemplateTypeCheck": true,
|
||||
"strictInjectionParameters": true
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user