From e3f6ae775e42d1a5403838393ea3ed4d780681e7 Mon Sep 17 00:00:00 2001 From: Onu Date: Fri, 25 Aug 2023 02:08:40 +0900 Subject: [PATCH 1/2] feat: add search snippet by snippet title. --- src/app/constants.ts | 6 +- .../dashboard/dashboard-routing.module.ts | 9 +- src/app/modules/dashboard/dashboard.module.ts | 23 +-- .../services/snippet/snippet.service.ts | 31 ++++ .../dashboard/snippet/snippet.component.html | 61 +++++--- .../dashboard/snippet/snippet.component.ts | 134 +++++++++++++++++- 6 files changed, 227 insertions(+), 37 deletions(-) create mode 100644 src/app/modules/dashboard/services/snippet/snippet.service.ts diff --git a/src/app/constants.ts b/src/app/constants.ts index 2a26af8..9dc3b71 100644 --- a/src/app/constants.ts +++ b/src/app/constants.ts @@ -1,7 +1,7 @@ export const route = { SIGNUP: 'signup', LOGIN: 'login', - SNIPPETS: 'dashboard/snippets' + SNIPPETS: 'dashboard/snippets', }; export const restAPI = { @@ -9,5 +9,7 @@ export const restAPI = { SIGN_UP: '/users/open', SNIPPETS: '/snippets', LANGUAGES: '/languages', - TAGS: '/tags' + TAGS: '/tags', + SEARCH_QUERY: '/snippets/search/?', + SEARCH: '/snippets/search' }; diff --git a/src/app/modules/dashboard/dashboard-routing.module.ts b/src/app/modules/dashboard/dashboard-routing.module.ts index 86c2f2f..18ebb09 100644 --- a/src/app/modules/dashboard/dashboard-routing.module.ts +++ b/src/app/modules/dashboard/dashboard-routing.module.ts @@ -1,5 +1,5 @@ -import { NgModule } from '@angular/core'; -import { RouterModule, Routes } from '@angular/router'; +import {NgModule} from '@angular/core'; +import {RouterModule, Routes} from '@angular/router'; import {SnippetComponent} from "./snippet/snippet.component"; import {AuthGuard} from "../../services/auth/auth.guard"; import {route} from "../../constants"; @@ -10,11 +10,12 @@ const routes: Routes = [ component: SnippetComponent, canActivate: [AuthGuard] }, - { path: '**', redirectTo: route.SNIPPETS} + {path: '**', redirectTo: route.SNIPPETS} ]; @NgModule({ imports: [RouterModule.forChild(routes)], exports: [RouterModule] }) -export class DashboardRoutingModule { } +export class DashboardRoutingModule { +} diff --git a/src/app/modules/dashboard/dashboard.module.ts b/src/app/modules/dashboard/dashboard.module.ts index 1e7a720..a491450 100644 --- a/src/app/modules/dashboard/dashboard.module.ts +++ b/src/app/modules/dashboard/dashboard.module.ts @@ -1,15 +1,17 @@ -import { NgModule } from '@angular/core'; -import { CommonModule } from '@angular/common'; +import {NgModule} from '@angular/core'; +import {CommonModule} from '@angular/common'; -import { DashboardRoutingModule } from './dashboard-routing.module'; +import {DashboardRoutingModule} from './dashboard-routing.module'; import {SnippetComponent} from "./snippet/snippet.component"; -import { DashboardComponent } from './dashboard.component'; -import { SnippetShowDialogComponent } from './components/snippet-show-dialog/snippet-show-dialog.component'; -import { SnippetCreateEditDialogComponent } from './components/snippet-create-edit-dialog/snippet-create-edit-dialog.component'; +import {DashboardComponent} from './dashboard.component'; +import {SnippetShowDialogComponent} from './components/snippet-show-dialog/snippet-show-dialog.component'; +import { + SnippetCreateEditDialogComponent +} from './components/snippet-create-edit-dialog/snippet-create-edit-dialog.component'; import {FormsModule} from "@angular/forms"; -import { NgxCodeJarComponent } from './components/editor/NgxCodeJar.component'; -import { NavbarComponent } from './components/navbar/navbar.component'; -import { SyntaxHighlightDirective } from './directives/SyntaxHighlight/syntax-highlight.directive'; +import {NgxCodeJarComponent} from './components/editor/NgxCodeJar.component'; +import {NavbarComponent} from './components/navbar/navbar.component'; +import {SyntaxHighlightDirective} from './directives/SyntaxHighlight/syntax-highlight.directive'; @NgModule({ @@ -28,4 +30,5 @@ import { SyntaxHighlightDirective } from './directives/SyntaxHighlight/syntax-hi FormsModule, ] }) -export class DashboardModule { } +export class DashboardModule { +} diff --git a/src/app/modules/dashboard/services/snippet/snippet.service.ts b/src/app/modules/dashboard/services/snippet/snippet.service.ts new file mode 100644 index 0000000..5718291 --- /dev/null +++ b/src/app/modules/dashboard/services/snippet/snippet.service.ts @@ -0,0 +1,31 @@ +import {Injectable} from '@angular/core'; +import {BehaviorSubject, Observable} from "rxjs"; +import {Snippet} from "../../snippet/snippet.component"; + +@Injectable({ + providedIn: 'any' +}) + +export class SnippetService { + + private $_snippets: BehaviorSubject = new BehaviorSubject([{}] as Snippet[]); + + constructor() { + + } + + getSnippet(): Observable { + return this.$_snippets.asObservable(); + } + + clearSnippet(): void { + this.$_snippets.next([{}]); + } + + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + insertSnippets(snippets: Snippet[]): void { + //Todo: Log dump the error trace. + this.$_snippets.next(snippets); + } + +} diff --git a/src/app/modules/dashboard/snippet/snippet.component.html b/src/app/modules/dashboard/snippet/snippet.component.html index e93f5dc..5897377 100644 --- a/src/app/modules/dashboard/snippet/snippet.component.html +++ b/src/app/modules/dashboard/snippet/snippet.component.html @@ -1,16 +1,16 @@ @@ -21,14 +21,35 @@
-

- - - - -

+

-

@@ -68,9 +89,9 @@
-
+
- +
@@ -80,6 +101,12 @@

Create Snippet

+ +
+
+
Search Results {{searchResultCount}}
+
+
diff --git a/src/app/modules/dashboard/snippet/snippet.component.ts b/src/app/modules/dashboard/snippet/snippet.component.ts index 0785e02..ec5b127 100644 --- a/src/app/modules/dashboard/snippet/snippet.component.ts +++ b/src/app/modules/dashboard/snippet/snippet.component.ts @@ -1,13 +1,17 @@ import {Component, OnInit} from '@angular/core'; import {RestService} from "../../../services/rest/rest.service"; -import {catchError, map, Observable} from "rxjs"; -import {restAPI} from "../../../constants"; +import {BehaviorSubject, catchError, debounceTime, map, Observable} from "rxjs"; +import {restAPI, route} from "../../../constants"; import {ErrorService} from "../../../services/error/error.service"; +import {HttpParams} from "@angular/common/http"; +import {ActivatedRoute, Router} from "@angular/router"; +import {SnippetService} from "../services/snippet/snippet.service"; @Component({ selector: 'app-dashboard', templateUrl: './snippet.component.html', styleUrls: ['./snippet.component.css'], + providers: [SnippetService] }) export class SnippetComponent implements OnInit { private _snippets: any; @@ -16,8 +20,16 @@ export class SnippetComponent implements OnInit { private _modalActive: boolean = false; private _modalCreateEditActive: boolean = false; private _snippet: Snippet = {}; + private _searchResult: SearchResult[] = []; + private _showSearchMenu: boolean = false; + private _showSearchResultHeader: boolean = false; + private _snippetsSearchResult: Snippet[] = []; + private $_searchQueryStr: BehaviorSubject = new BehaviorSubject(""); + private _searchResultCount: number = 0; - constructor(private _rest: RestService, private _error: ErrorService) { + constructor(private _rest: RestService, private _error: ErrorService, + private _route: ActivatedRoute, private _router: Router, + private _snippetService: SnippetService) { } get isModalActive(): boolean { @@ -44,10 +56,59 @@ export class SnippetComponent implements OnInit { return this._tags; } + get searchResult(): SearchResult[] { + return this._searchResult; + } + + get searchResultFirstThree(): SearchResult[] { + return this._searchResult.slice(0, 3); + } + + get showSearchMenu(): boolean { + return this._showSearchMenu; + } + + get showSearchResultHeader(): boolean { + return this._showSearchResultHeader; + } + + get searchResultCount(): number { + return this._searchResultCount; + } + ngOnInit(): void { + this.$_searchQueryStr.pipe( + debounceTime(500) + ).subscribe({ + next: (query) => { + if (query && query.length > 0) { + this.getSearchSnippetQuery(query); + } + } + }); + + this._snippetService.getSnippet().subscribe({ + next: value => { + this._snippetsSearchResult = value as Snippet[]; + } + }); + + this._route.queryParams + .subscribe({ + next: (params) => { + if (params['search']) { + this._snippets = this._snippetsSearchResult; + this._showSearchResultHeader = true; + } else { + this._showSearchResultHeader = false; + this.loadSnippetList(); + } + } + } + ); this.loadLanguageList(); this.loadTagList(); - this.loadSnippetList(); + } showCodeSnippet(snippetId: number): void { @@ -88,6 +149,41 @@ export class SnippetComponent implements OnInit { this._snippet = {}; } + onSearchChange(event: Event): void { + this.$_searchQueryStr.next((event.target as HTMLInputElement).value); + } + + + showSearchResult(searchResult: SearchResult[]): void { + this._showSearchMenu = false; + + const ids = this.getSnippetIds(searchResult); + let api = this._rest.url(restAPI.SEARCH); + this._rest.post(api, {ids: ids}).subscribe({ + next: value => { + const searchRes = value as Snippet[]; + this._searchResultCount = searchRes.length; + this._snippetService.insertSnippets(searchRes); + this._router.navigate([route.SNIPPETS], + { + queryParams: {search: true}, + queryParamsHandling: 'merge' + }); + }, + error: err => { + this._error.insertMessage("Snippet list load error.", err); + } + }); + } + + closeSearchMode(): void { + this._snippetsSearchResult = []; + this._searchResult = []; + this._showSearchResultHeader = false; + this._snippetService.clearSnippet(); + this._router.navigate([route.SNIPPETS]); + } + private loadSnippet(id: number): Observable { const api = this._rest.url(`${restAPI.SNIPPETS}/${id}`); return this._rest.get(api).pipe( @@ -156,6 +252,32 @@ export class SnippetComponent implements OnInit { } }); } + + private getSearchSnippetQuery(query: string): void { + const params = new HttpParams() + .set('query', query) + .set('limit', 4); + let api = this._rest.url(restAPI.SEARCH_QUERY + params.toString()); + + this._rest.get(api).subscribe({ + next: value => { + this._searchResult = value as SearchResult[]; + this._showSearchMenu = this._searchResult.length > 3; + }, + error: err => { + this._error.insertMessage("Search error.", err); + } + }); + } + + private getSnippetIds(searchResult: SearchResult[]): number[] { + let ids: number[] = []; + searchResult.forEach(item => { + ids.push(item.id); + }); + return ids; + } + } export interface Snippet { @@ -177,3 +299,7 @@ export interface Tag { name: string } +export interface SearchResult { + id: number, + title: string +} From 85530fa07c55c6ab8aada8cefeafd8ddc860172e Mon Sep 17 00:00:00 2001 From: Onu Date: Sun, 27 Aug 2023 13:20:11 +0900 Subject: [PATCH 2/2] test: add search snippet unit test --- .../snippet/snippet.component.spec.ts | 338 +++++++++++++++++- 1 file changed, 324 insertions(+), 14 deletions(-) diff --git a/src/app/modules/dashboard/snippet/snippet.component.spec.ts b/src/app/modules/dashboard/snippet/snippet.component.spec.ts index 4b60ecc..92ee1e2 100644 --- a/src/app/modules/dashboard/snippet/snippet.component.spec.ts +++ b/src/app/modules/dashboard/snippet/snippet.component.spec.ts @@ -1,33 +1,47 @@ -import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; +import {ComponentFixture, discardPeriodicTasks, fakeAsync, flush, TestBed, tick} from '@angular/core/testing'; -import {SnippetComponent} from './snippet.component'; +import {SearchResult, SnippetComponent} from './snippet.component'; import {HttpClientTestingModule, HttpTestingController} from "@angular/common/http/testing"; import {RestService} from "../../../services/rest/rest.service"; import {AuthService} from "../../../services/auth/auth.service"; -import {Router} from "@angular/router"; +import {ActivatedRoute, Router} from "@angular/router"; import {Observable, of, throwError} from "rxjs"; import {DashboardModule} from "../dashboard.module"; -import {restAPI} from "../../../constants"; +import {restAPI, route} from "../../../constants"; import {AppConfig} from "../../../app-config"; import {ClipboardModule} from "@angular/cdk/clipboard"; import {ErrorService} from "../../../services/error/error.service"; -import {HttpErrorResponse} from "@angular/common/http"; +import {SnippetService} from "../services/snippet/snippet.service"; +import {HttpErrorResponse} from '@angular/common/http'; describe('SnippetComponent', () => { let component: SnippetComponent; let fixture: ComponentFixture; let auth: AuthService; let rest: RestService; + let mockRouter = {navigate: jasmine.createSpy('navigate')}; + const fakeActivatedRoute = { + queryParams: new Observable((observer) => { + observer.next({}); + }) + }; beforeEach(async () => { await TestBed.configureTestingModule({ imports: [HttpClientTestingModule, DashboardModule, ClipboardModule], providers: [ ErrorService, RestService, + SnippetService, + // { + // provide: Router, useClass: class { + // navigate = jasmine.createSpy("navigate").and.resolveTo(true); + // } + // }, + { + provide: Router, useValue: mockRouter + }, { - provide: Router, useClass: class { - navigate = jasmine.createSpy("navigate").and.resolveTo(true); - } + provide: ActivatedRoute, useValue: fakeActivatedRoute } ], declarations: [SnippetComponent] @@ -40,7 +54,7 @@ describe('SnippetComponent', () => { component = fixture.componentInstance; auth = TestBed.inject(AuthService); rest = TestBed.inject(RestService); - + // router = TestBed.inject(Router); fixture.detectChanges(); }); @@ -64,6 +78,21 @@ describe('SnippetComponent', () => { case createUrl(`${restAPI.SNIPPETS}/1`): { return of(snippet); } + case createUrl(`${restAPI.SEARCH_QUERY}query=title&limit=4`): { + return of(searchResultMetaInfo); + } + default: { + break; + } + } + } + + function fakePost(url: string, data: any): Observable | undefined { + + switch (url) { + case createUrl(restAPI.SEARCH): { + return of(searchResult); + } default: { break; } @@ -188,6 +217,53 @@ describe('SnippetComponent', () => { ] } ]; + const searchResultMetaInfo = [ + {id: 1, title: "title 1"}, + {id: 2, title: "title 2"}, + {id: 3, title: "title 3"}, + {id: 4, title: "title 4"}, + ]; + const searchResult = [ + { + "id": 1, + "title": "title 1", + "language": "php", + "tags": [] + }, + { + "id": 2, + "title": "title 2", + "language": "java", + "tags": [ + { + "id": 1, + "name": "general" + } + ] + }, + { + "id": 3, + "title": "title 3", + "language": "c++", + "tags": [ + { + "id": 1, + "name": "general" + } + ] + }, + { + "id": 4, + "title": "title 4", + "language": "php", + "tags": [ + { + "id": 1, + "name": "general" + } + ] + }, + ]; let showModal: boolean = false; let showEditModal: boolean = false; @@ -286,6 +362,7 @@ describe('SnippetComponent', () => { const el_snippets = fixture.nativeElement.querySelectorAll("table.table>tbody>tr"); expect(el_snippets.length).toEqual(6); + discardPeriodicTasks(); })); it('should show new snippet', fakeAsync(() => { @@ -314,6 +391,7 @@ describe('SnippetComponent', () => { fixture.detectChanges(); expect(el_snippet_show_dialog.getAttribute("ng-reflect-modal-active")).toBe('false'); expect(el_snippet_edit_dialog.getAttribute("ng-reflect-modal-active")).toBe('true'); + discardPeriodicTasks(); })); @@ -326,7 +404,6 @@ describe('SnippetComponent', () => { expect(el_snippet).toBeTruthy(); el_snippet.dispatchEvent(new Event('click')); tick(); - fixture.detectChanges(); let el_snippet_show_dialog = fixture.nativeElement.querySelector("app-snippet-show-dialog"); @@ -345,19 +422,20 @@ describe('SnippetComponent', () => { tick(); fixture.detectChanges(); expect(el_snippet_show_dialog.getAttribute("ng-reflect-modal-active")).toBe('false'); + discardPeriodicTasks(); })); it('should show the snippet edit', fakeAsync(() => { spyOn(Object.getPrototypeOf(rest), 'get').and.callFake(fakeGet); component.ngOnInit(); - fixture.detectChanges(); + fixture.autoDetectChanges(); const el_snippet = fixture.nativeElement.querySelector("table.table>tbody>tr:first-child>td:first-child"); expect(el_snippet).toBeTruthy(); el_snippet.dispatchEvent(new Event('click')); tick(); - fixture.detectChanges(); + fixture.autoDetectChanges(); let el_snippet_show_dialog = fixture.nativeElement.querySelector("app-snippet-show-dialog"); expect(el_snippet_show_dialog.getAttribute("ng-reflect-modal-active")).toBe('true'); @@ -373,7 +451,7 @@ describe('SnippetComponent', () => { el_edit.dispatchEvent(new Event('click')); tick(); - fixture.detectChanges(); + fixture.autoDetectChanges(); expect(el_snippet_show_dialog.getAttribute("ng-reflect-modal-active")).toBe('false'); expect(el_snippet_edit_dialog.getAttribute("ng-reflect-modal-active")).toBe('true'); @@ -386,8 +464,240 @@ describe('SnippetComponent', () => { el_close.dispatchEvent(new Event('click')); tick(); - fixture.detectChanges(); + fixture.autoDetectChanges(); expect(el_snippet_show_dialog.getAttribute("ng-reflect-modal-active")).toBe('false'); expect(el_snippet_edit_dialog.getAttribute("ng-reflect-modal-active")).toBe('false'); + flush(); + discardPeriodicTasks(); })); + it('should show search query', fakeAsync(() => { + spyOn(Object.getPrototypeOf(rest), 'get').and.callFake(fakeGet); + const spyQuerySnippet = spyOn(component, 'getSearchSnippetQuery').and.callThrough(); + const spyOnSearchInputChange = spyOn(component, 'onSearchChange').and.callThrough(); + component.ngOnInit(); + const el_snippet_search = fixture.nativeElement.querySelector("#search-snippet"); + expect(el_snippet_search).toBeTruthy(); + el_snippet_search.value = "title"; + el_snippet_search.dispatchEvent(new Event('input')); + tick(1000); + fixture.detectChanges(); + expect(spyOnSearchInputChange).toHaveBeenCalled(); + expect(spyQuerySnippet).toHaveBeenCalled(); + discardPeriodicTasks(); + })); + it('should show search search result', fakeAsync(() => { + spyOn(Object.getPrototypeOf(rest), 'post').and.callFake(fakePost); + const spyShowSearchResult = spyOn(component, 'showSearchResult').and.callThrough(); + component['_searchResult'] = searchResultMetaInfo as SearchResult[]; + component.ngOnInit(); + const el_snippet_search_btn = fixture.nativeElement.querySelector("#search-btn"); + el_snippet_search_btn.dispatchEvent(new Event('click')); + tick(); + fixture.detectChanges(); + expect(spyShowSearchResult).toHaveBeenCalled(); + expect(mockRouter.navigate).toHaveBeenCalledWith([route.SNIPPETS], + { + queryParams: {search: true}, + queryParamsHandling: 'merge', + }); + discardPeriodicTasks(); + })); +}); + +describe('SnippetComponentSearch', () => { + let component: SnippetComponent; + let fixture: ComponentFixture; + let auth: AuthService; + let rest: RestService; + let mockRouter = {navigate: jasmine.createSpy('navigate')}; + const fakeActivatedRoute = { + queryParams: new Observable((observer) => { + observer.next({search: true}); + }) + }; + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, DashboardModule, ClipboardModule], + providers: [ + ErrorService, + RestService, + SnippetService, + { + provide: Router, useValue: mockRouter + }, + { + provide: ActivatedRoute, useValue: fakeActivatedRoute + } + ], + declarations: [SnippetComponent] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SnippetComponent); + component = fixture.componentInstance; + auth = TestBed.inject(AuthService); + rest = TestBed.inject(RestService); + fixture.detectChanges(); + }); + + + function createUrl(path: string): string { + return AppConfig.BASE_URL + path; + } + + function fakeGet(url: string): Observable | undefined { + + switch (url) { + case createUrl(restAPI.LANGUAGES): { + return of(languageList); + } + case createUrl(restAPI.TAGS): { + return of(tagList); + } + case createUrl(restAPI.SNIPPETS): { + return of(snippets); + } + case createUrl(`${restAPI.SNIPPETS}/1`): { + return of(snippet); + } + default: { + break; + } + } + } + + let snippet = { + "id": 2, + "title": "title 1", + "language": "java", + "tags": [ + { + "id": 1, + "name": "general" + } + ], + "snippet": "", + "urls": [ + "asdsd" + ] + }; + const languageList = [ + { + "id": 1, + "name": "java" + }, + { + "id": 2, + "name": "php" + }, + { + "id": 3, + "name": "c++" + }, + { + "id": 4, + "name": "javascript" + } + ]; + const tagList = [ + { + "id": 1, + "name": "general" + }, + { + "id": 2, + "name": "world" + }, + { + "id": 3, + "name": "auth" + } + ]; + const snippets = [ + { + "id": 1, + "title": "title 1", + "language": "php", + "tags": [] + }, + { + "id": 2, + "title": "title 2", + "language": "java", + "tags": [ + { + "id": 1, + "name": "general" + } + ] + }, + { + "id": 3, + "title": "title 3", + "language": "c++", + "tags": [ + { + "id": 1, + "name": "general" + } + ] + }, + { + "id": 4, + "title": "title 4", + "language": "php", + "tags": [ + { + "id": 1, + "name": "general" + } + ] + }, + { + "id": 5, + "title": "title 5", + "language": "php", + "tags": [ + { + "id": 1, + "name": "general" + }, + { + "id": 2, + "name": "world" + }, + { + "id": 3, + "name": "auth" + } + ] + }, + { + "id": 6, + "title": "title 6", + "language": "php", + "tags": [ + { + "id": 2, + "name": "world" + } + ] + } + ]; + let showModal: boolean = false; + let showEditModal: boolean = false; + + it('should create search', () => { + const spy = spyOnProperty(component, 'snippets', 'get').and.callThrough(); + expect(component.snippets).toEqual([{}]); + expect(spy).toHaveBeenCalled(); + expect(component).toBeTruthy(); + }); + + it('should close search result page', () => { + component.closeSearchMode(); + expect(mockRouter.navigate).toHaveBeenCalledWith([route.SNIPPETS]); + }); });