diff --git a/src/app/pages/settings/chains/chain-add/chain-add.html b/src/app/pages/settings/chains/chain-add/chain-add.html new file mode 100644 index 0000000..45e394a --- /dev/null +++ b/src/app/pages/settings/chains/chain-add/chain-add.html @@ -0,0 +1,8 @@ +

{{'settings.chain.new_chain'|translate|upperfirst}}

+ + diff --git a/src/app/pages/settings/chains/chain-add/chain-add.scss b/src/app/pages/settings/chains/chain-add/chain-add.scss new file mode 100644 index 0000000..72d4862 --- /dev/null +++ b/src/app/pages/settings/chains/chain-add/chain-add.scss @@ -0,0 +1,11 @@ +:host { + display: flex; + flex-direction: column; + height: 100%; +} + +h3 { + margin-top: 0; +} + + diff --git a/src/app/pages/settings/chains/chain-add/chain-add.spec.ts b/src/app/pages/settings/chains/chain-add/chain-add.spec.ts new file mode 100644 index 0000000..5519202 --- /dev/null +++ b/src/app/pages/settings/chains/chain-add/chain-add.spec.ts @@ -0,0 +1,69 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ChainAdd } from './chain-add'; +import { ChainDAO } from '../../../../dao/ChainDAO'; +import { vi } from 'vitest'; +import { provideTranslateService } from '@ngx-translate/core'; +import { Router } from '@angular/router'; +import { ChainFormGroup } from '../chain-formgroup'; +import { FormControl } from '@angular/forms'; +import { By } from '@angular/platform-browser'; +import { ImageStorage } from '../../../../services/image-storage'; + +describe('ChainAdd', () => { + let component: ChainAdd; + let fixture: ComponentFixture; + + let chainDAO: Partial; + let imageStorage: Partial; + let router: Partial; + + beforeEach(async () => { + chainDAO = { + insert: vi.fn().mockResolvedValue({ + rows: [], + }), + }; + + imageStorage = { + saveImage: vi.fn().mockResolvedValue('mock.jpg'), + }; + + router = { + navigate: vi.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [ChainAdd], + providers: [ + provideTranslateService(), + { provide: ChainDAO, useValue: chainDAO }, + { provide: ImageStorage, useValue: imageStorage }, + { provide: Router, useValue: router }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ChainAdd); + component = fixture.componentInstance; + }); + + it('should create', async () => { + await fixture.whenStable(); + expect(component).toBeTruthy(); + }); + + it('should insert chain and store image', async () => { + const saveSpy = vi.spyOn(component, 'save'); + (component).form = new ChainFormGroup({ + name: new FormControl('Mock'), + image: new FormControl(new File([], 'mock')), + }); + await fixture.whenStable(); + const actionBtn = fixture.debugElement.query(By.css('app-action-btn')); + actionBtn.triggerEventHandler('click'); + await fixture.whenStable(); + expect(saveSpy).toHaveBeenCalled(); + expect(imageStorage.saveImage).toHaveBeenCalledOnce(); + expect(chainDAO.insert).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/pages/settings/chains/chain-add/chain-add.ts b/src/app/pages/settings/chains/chain-add/chain-add.ts new file mode 100644 index 0000000..57aede3 --- /dev/null +++ b/src/app/pages/settings/chains/chain-add/chain-add.ts @@ -0,0 +1,45 @@ +import { Component, inject } from '@angular/core'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { ImageStorage } from '../../../../services/image-storage'; +import { ChainDAO } from '../../../../dao/ChainDAO'; +import { Chain } from '../../../../models/Chain'; +import { ChainFormGroup } from '../chain-formgroup'; +import { ChainForm } from "../chain-form/chain-form"; +import { Router } from '@angular/router'; +import { ActionBtn } from "../../../../components/action-btn/action-btn"; +import { TranslatePipe } from '@ngx-translate/core'; +import { UpperfirstPipe } from "../../../../pipes/upperfirst-pipe"; + +@Component({ + selector: 'app-chain-add', + imports: [MatFormFieldModule, MatInputModule, ReactiveFormsModule, ChainForm, ActionBtn, TranslatePipe, UpperfirstPipe], + templateUrl: './chain-add.html', + styleUrl: './chain-add.scss', +}) +export class ChainAdd { + private readonly chainDAO = inject(ChainDAO); + private readonly imageStorage = inject(ImageStorage); + private readonly router = inject(Router); + readonly form = new ChainFormGroup + + async save() { + const name = this.form.controls.name.value; + const img = this.form.controls.image.value; + try { + //TODO: the sqlite bridge can't handle null as param + const chain = new Chain(name, ''); + if (img) { + const imgFileName = await this.imageStorage.saveImage(img); + chain.image = imgFileName; + } + await this.chainDAO.insert(chain); + this.router.navigate(['settings', 'chains']) + } catch (e) { + console.error(e) + //TODO: report problem + } + } + +} diff --git a/src/app/pages/settings/chains/chain-edit/chain-edit.html b/src/app/pages/settings/chains/chain-edit/chain-edit.html new file mode 100644 index 0000000..6b12544 --- /dev/null +++ b/src/app/pages/settings/chains/chain-edit/chain-edit.html @@ -0,0 +1,9 @@ +

{{'settings.chain.edit_chain'|translate|upperfirst}}

+ + \ No newline at end of file diff --git a/src/app/pages/settings/chains/chain-edit/chain-edit.scss b/src/app/pages/settings/chains/chain-edit/chain-edit.scss new file mode 100644 index 0000000..6a25b44 --- /dev/null +++ b/src/app/pages/settings/chains/chain-edit/chain-edit.scss @@ -0,0 +1,5 @@ +:host { + display: flex; + flex-direction: column; + height: 100%; +} \ No newline at end of file diff --git a/src/app/pages/settings/chains/chain-edit/chain-edit.spec.ts b/src/app/pages/settings/chains/chain-edit/chain-edit.spec.ts new file mode 100644 index 0000000..0bc7cc5 --- /dev/null +++ b/src/app/pages/settings/chains/chain-edit/chain-edit.spec.ts @@ -0,0 +1,102 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ChainEdit } from './chain-edit'; +import { ActivatedRoute, Router } from '@angular/router'; +import { BehaviorSubject } from 'rxjs'; +import { Chain } from '../../../../models/Chain'; +import { provideTranslateService } from '@ngx-translate/core'; +import { ChainDAO } from '../../../../dao/ChainDAO'; +import { By } from '@angular/platform-browser'; +import { ImageStorage } from '../../../../services/image-storage'; + +const CHAINS_PATH = ['settings', 'chains']; +const CHAIN_MOCK = new Chain('Mock', '', 1); +describe('ChainEdit', () => { + let component: ChainEdit; + let fixture: ComponentFixture; + + let activatedRoute: Partial; + let chainDAO: Partial; + let imageStorage: Partial; + let router: Partial; + + const dataSubject = new BehaviorSubject({ chain: CHAIN_MOCK }); + beforeEach(async () => { + activatedRoute = { + data: dataSubject, + }; + chainDAO = { + update: vi.fn().mockResolvedValue({ + rows: [], + }), + }; + imageStorage = { + getImage: vi.fn(), + saveImage: vi.fn(), + deleteImage: vi.fn(), + }; + router = { + navigate: vi.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [ChainEdit], + providers: [ + provideTranslateService(), + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: ChainDAO, useValue: chainDAO }, + { provide: ImageStorage, useValue: imageStorage }, + { provide: Router, useValue: router }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ChainEdit); + component = fixture.componentInstance; + }); + + it('should create', async () => { + await fixture.whenStable(); + expect(component).toBeTruthy(); + }); + + describe('on form update', () => { + it('should update chain name after modifying name', async () => { + await fixture.whenStable(); + const chainNameInput = fixture.debugElement.query(By.css('[formControlName="name"]')); + chainNameInput.triggerEventHandler('input', { target: { value: 'name update mock' } }); + fixture.whenStable(); + const actionBtn = fixture.debugElement.query(By.css('app-action-btn')); + actionBtn.triggerEventHandler('click'); + await fixture.whenStable(); + expect(chainDAO.update).toHaveBeenCalledExactlyOnceWith(component['chain'], { + id: CHAIN_MOCK.id, + }); + expect(router.navigate).toHaveBeenCalledExactlyOnceWith(CHAINS_PATH); + }); + + it('should update chain image after modifying image', async () => { + const NEW_IMG_MOCK = new File([], 'new_img_mock.png', { type: 'image/png' }); + const CHAIN_MOCK = new Chain('Mock', 'img_mock.jpeg', 1); + imageStorage.getImage = vi + .fn() + .mockResolvedValue(new File([], CHAIN_MOCK.image!, { type: 'image/jpeg' })); + imageStorage.saveImage = vi.fn().mockResolvedValue(NEW_IMG_MOCK.name); + + dataSubject.next({ chain: CHAIN_MOCK }); + await fixture.whenStable(); + //User's select a new image + component.form.controls.image.patchValue(NEW_IMG_MOCK); + await fixture.whenStable(); + const actionBtn = fixture.debugElement.query(By.css('app-action-btn')); + actionBtn.triggerEventHandler('click'); + await fixture.whenStable(); + expect(imageStorage.getImage).toHaveBeenCalledWith(CHAIN_MOCK.image); + expect(imageStorage.saveImage).toHaveBeenCalledExactlyOnceWith(NEW_IMG_MOCK); + expect(imageStorage.deleteImage).toHaveBeenCalledExactlyOnceWith(CHAIN_MOCK.image); + expect(chainDAO.update).toHaveBeenCalledExactlyOnceWith(component['chain'], { + id: CHAIN_MOCK.id, + }); + expect(router.navigate).toHaveBeenCalledExactlyOnceWith(CHAINS_PATH); + }); + }); +}); diff --git a/src/app/pages/settings/chains/chain-edit/chain-edit.ts b/src/app/pages/settings/chains/chain-edit/chain-edit.ts new file mode 100644 index 0000000..7524d94 --- /dev/null +++ b/src/app/pages/settings/chains/chain-edit/chain-edit.ts @@ -0,0 +1,81 @@ +import { ChangeDetectorRef, Component, inject, OnInit } from '@angular/core'; +import { ChainFormGroup } from '../chain-formgroup'; +import { ChainForm } from '../chain-form/chain-form'; +import { ActivatedRoute, Router } from '@angular/router'; +import { Observable, take, tap } from 'rxjs'; +import { Chain } from '../../../../models/Chain'; +import { ImageStorage } from '../../../../services/image-storage'; +import { ActionBtn } from '../../../../components/action-btn/action-btn'; +import { ChainDAO } from '../../../../dao/ChainDAO'; +import { TranslatePipe } from '@ngx-translate/core'; +import { UpperfirstPipe } from "../../../../pipes/upperfirst-pipe"; + +@Component({ + selector: 'app-chain-edit', + imports: [ChainForm, ActionBtn, TranslatePipe, UpperfirstPipe], + templateUrl: './chain-edit.html', + styleUrl: './chain-edit.scss', +}) +export class ChainEdit implements OnInit { + readonly form = new ChainFormGroup(); + protected readonly activatedRoute = inject(ActivatedRoute); + private readonly cd = inject(ChangeDetectorRef); + private chain?: Chain; + private readonly chainDAO = inject(ChainDAO); + private readonly imageStorage = inject(ImageStorage); + private readonly router = inject(Router); + + ngOnInit() { + (>this.activatedRoute.data) + .pipe( + take(1), + tap((data) => (this.chain = new Chain(data.chain.name, data.chain.image, data.chain.id))), + tap(async (data) => await this.patchForm(data.chain)), + ) + .subscribe(); + } + + get disabled() { + return (this.form.invalid || this.form.pristine) + } + + async patchForm(chain: Chain) { + try { + this.form.controls.name.patchValue(chain.name); + if (chain.image) { + const imgName = chain.image; + const blob = await this.imageStorage.getImage(imgName); + const file = new File([blob], imgName, { type: blob.type }); + this.form.controls.image.patchValue(file); + this.cd.detectChanges(); + } + } catch (e) { + console.error(e); + //TODO: reportar error + } + } + + async update() { + try { + if (this.chain) { + this.chain.name = this.form.controls.name.value; + const formImgFile = this.form.controls.image.value; + let imgName = this.chain.name; + if (formImgFile) { + if (formImgFile.name !== imgName) { + imgName = await this.imageStorage.saveImage(formImgFile); + if (this.chain.image) { + this.imageStorage.deleteImage(this.chain.image); + } + this.chain.image = imgName; + } + } + await this.chainDAO.update(this.chain, { id: this.chain.id }); + this.router.navigate(['settings', 'chains']); + } + } catch (e) { + console.error(e); + //TODO: reportar error + } + } +} diff --git a/src/app/pages/settings/chains/chain-list/chain-list.html b/src/app/pages/settings/chains/chain-list/chain-list.html new file mode 100644 index 0000000..8f3e8dd --- /dev/null +++ b/src/app/pages/settings/chains/chain-list/chain-list.html @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/app/pages/settings/chains/chain-list/chain-list.spec.ts b/src/app/pages/settings/chains/chain-list/chain-list.spec.ts new file mode 100644 index 0000000..755c2c1 --- /dev/null +++ b/src/app/pages/settings/chains/chain-list/chain-list.spec.ts @@ -0,0 +1,61 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ChainList } from './chain-list'; +import { ActivatedRoute, Router } from '@angular/router'; +import { BehaviorSubject } from 'rxjs'; +import { Chain } from '../../../../models/Chain'; +import { By } from '@angular/platform-browser'; + +const ADD_PATH = ['settings', 'chains', 'add']; +const EDIT_PATH = ['settings', 'chains', 'edit']; +describe('ChainList', () => { + let component: ChainList; + let fixture: ComponentFixture; + + let activatedRoute: Partial; + let router: Partial; + + let EDIT_PATH_MOCK: string[]; + + const CHAIN_MOCK = new Chain('Mock', '', 1); + const dataSubject = new BehaviorSubject({ chains: [CHAIN_MOCK] }); + beforeEach(async () => { + activatedRoute = { + data: dataSubject, + }; + + router = { + navigate: vi.fn(), + }; + + EDIT_PATH_MOCK = [...EDIT_PATH, String(CHAIN_MOCK.id)]; + + await TestBed.configureTestingModule({ + imports: [ChainList], + providers: [ + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: Router, useValue: router }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ChainList); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should navigate to edit on edit action', () => { + const editBtn = fixture.debugElement.query(By.css('[matIconButton]')); + editBtn.triggerEventHandler('click'); + expect(router.navigate).toHaveBeenCalledExactlyOnceWith(EDIT_PATH_MOCK); + }); + + it('should navigate to add on add btn click', () => { + const addBtn = fixture.debugElement.query(By.css('app-floating-big-btn')); + addBtn.triggerEventHandler('bigClick'); + expect(router.navigate).toHaveBeenCalledExactlyOnceWith(ADD_PATH); + }); +}); diff --git a/src/app/pages/settings/chains/chain-list/chain-list.ts b/src/app/pages/settings/chains/chain-list/chain-list.ts new file mode 100644 index 0000000..2026a55 --- /dev/null +++ b/src/app/pages/settings/chains/chain-list/chain-list.ts @@ -0,0 +1,40 @@ +import { Component, inject } from '@angular/core'; +import { SimpleListWActions } from '../../../../components/simple-list-w-actions/simple-list-w-actions'; +import { ActivatedRoute, Router } from '@angular/router'; +import { map } from 'rxjs'; +import { SimpleListItem } from '../../../../components/simple-list-w-actions/SimpleListItem'; +import { SimpleListItemAction } from '../../../../components/simple-list-w-actions/SimpleListItemAction'; +import { Chain } from '../../../../models/Chain'; +import { AsyncPipe } from '@angular/common'; +import { FloatingBigBtn } from "../../../../components/floating-big-btn/floating-big-btn"; + +@Component({ + selector: 'app-chain-list', + imports: [SimpleListWActions, AsyncPipe, FloatingBigBtn], + templateUrl: './chain-list.html', + styles: ``, +}) +export class ChainList { + private readonly router = inject(Router); + protected readonly activatedRoute = inject(ActivatedRoute); + + chains$ = this.activatedRoute.data.pipe( + map((data) => + (data['chains']).map( + (c, i) => + new SimpleListItem(String(c.id), c.name ?? '', [new SimpleListItemAction('edit', 'edit')]), + ), + ), + ); + + protected edit(action: { + action: string; + subject: string; +}) { + this.router.navigate(['settings', 'chains', 'edit', action.subject]) + } + + protected add() { + this.router.navigate(['settings', 'chains', 'add']); + } +} diff --git a/src/app/pages/settings/chains/chains.html b/src/app/pages/settings/chains/chains.html new file mode 100644 index 0000000..96533ce --- /dev/null +++ b/src/app/pages/settings/chains/chains.html @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/app/pages/settings/chains/chains.scss b/src/app/pages/settings/chains/chains.scss new file mode 100644 index 0000000..478abc9 --- /dev/null +++ b/src/app/pages/settings/chains/chains.scss @@ -0,0 +1,4 @@ +:host { + display: block; + height: 100%; +} diff --git a/src/app/pages/settings/chains/chains.spec.ts b/src/app/pages/settings/chains/chains.spec.ts new file mode 100644 index 0000000..7b34bbc --- /dev/null +++ b/src/app/pages/settings/chains/chains.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Chains } from './chains'; +import { provideTranslateService } from '@ngx-translate/core'; + +describe('Chains', () => { + let component: Chains; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Chains], + providers: [provideTranslateService({})] + }).compileComponents(); + + fixture = TestBed.createComponent(Chains); + component = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/pages/settings/chains/chains.ts b/src/app/pages/settings/chains/chains.ts new file mode 100644 index 0000000..088dca4 --- /dev/null +++ b/src/app/pages/settings/chains/chains.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; +import { MatListModule } from '@angular/material/list'; +import { SimpleLayout } from '../../../components/simple-layout/simple-layout'; +import { RouterOutlet } from '@angular/router'; + + +@Component({ + selector: 'app-chains', + imports: [MatListModule, SimpleLayout, RouterOutlet], + templateUrl: './chains.html', + styleUrl: './chains.scss', +}) +export class Chains {}