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 {}