feat(product): add crate and edit ops in settings

This commit is contained in:
2026-02-22 23:35:30 -03:00
parent f89a27320f
commit 82dd3c607a
30 changed files with 668 additions and 4 deletions

View File

@@ -33,7 +33,9 @@
"edit_establishment": "edit establishment"
},
"product": {
"products":"products"
"products":"products",
"new_product":"new product",
"edit_product":"edit product"
}
},
"common": {

View File

@@ -33,7 +33,9 @@
"edit_establishment": "editar establecimiento"
},
"product": {
"products":"productos"
"products":"productos",
"new_product":"nuevo producto",
"edit_product":"editar producto"
}
},
"common": {

View File

@@ -33,7 +33,9 @@
"edit_establishment": "edit establishment"
},
"product": {
"products":"products"
"products":"products",
"new_product":"new product",
"edit_product":"edit product"
}
},
"common": {

View File

@@ -0,0 +1,31 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProductAdd } from './product-add';
import { ProductSettings } from '../../services/product-settings';
import { provideTranslateService } from '@ngx-translate/core';
describe('ProductAdd', () => {
let component: ProductAdd;
let fixture: ComponentFixture<ProductAdd>;
let productSettings: Partial<ProductSettings>;
beforeEach(async () => {
productSettings = {
save: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [ProductAdd],
providers: [{ provide: ProductSettings, useValue: productSettings }, provideTranslateService()],
}).compileComponents();
fixture = TestBed.createComponent(ProductAdd);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,31 @@
import { Component, inject } from '@angular/core';
import { SettingsBaseAddEdit } from '../settings-base-add-edit/settings-base-add-edit';
import { ProductFormGroup } from '../../pages/settings/products/product-formgroup';
import { ActionBtn } from '../action-btn/action-btn';
import { TranslatePipe } from '@ngx-translate/core';
import { UpperfirstPipe } from '../../pipes/upperfirst-pipe';
import { ProductSettings } from '../../services/product-settings';
import { Product } from '../../models/Product';
@Component({
selector: 'app-product-add',
imports: [ActionBtn, TranslatePipe, UpperfirstPipe],
templateUrl: './../settings-base-add-edit/settings-base-add-edit.html',
styleUrl: './../settings-base-add-edit/settings-base-add-edit.scss',
})
export class ProductAdd extends SettingsBaseAddEdit {
btnText = 'common.save';
title = 'settings.product.new_product';
readonly form = new ProductFormGroup();
private readonly productSettings = inject(ProductSettings);
async submit() {
const product = new Product(
this.form.controls.barcode.value,
this.form.controls.name.value,
'',
);
await this.productSettings.save(product, this.form.controls.image.value);
}
}

View File

@@ -0,0 +1,74 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProductEdit } from './product-edit';
import { ActivatedRoute } from '@angular/router';
import { Product } from '../../models/Product';
import { BehaviorSubject } from 'rxjs';
import { ImageStorage } from '../../services/image-storage';
import { ProductSettings } from '../../services/product-settings';
import { provideTranslateService } from '@ngx-translate/core';
import { By } from '@angular/platform-browser';
describe('ProductEdit', () => {
let component: ProductEdit;
let fixture: ComponentFixture<ProductEdit>;
let activatedRoute: Partial<ActivatedRoute>;
let imageStorage: Partial<ImageStorage>;
let productSettings: Partial<ProductSettings>;
const dataSubject = new BehaviorSubject({ product: new Product('mock', 'mock', 'mock.jpg', 1) });
beforeEach(async () => {
activatedRoute = {
data: dataSubject,
};
imageStorage = {
getImage: vi.fn().mockResolvedValue(new Blob([], { type: 'image/png' })),
};
productSettings = {
update: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [ProductEdit],
providers: [
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: ImageStorage, useValue: imageStorage },
{ provide: ProductSettings, useValue: productSettings },
provideTranslateService(),
],
}).compileComponents();
fixture = TestBed.createComponent(ProductEdit);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should fetch image from imageStorage', () => {
expect(imageStorage.getImage).toHaveBeenCalledTimes(1);
});
//TODO: there is some sync issue by using a getter for the disabled state
it('should not call update on update btn click if form is unchanged', () => {
expect(component.disabled).toBe(true);
});
it('should call productSettings update on product update', async () => {
//User updates the product name
component.form.controls.name.patchValue('updated name mock');
component.form.controls.name.markAsDirty();
await fixture.whenStable();
expect(component.disabled).toBe(false);
const updateBtn = fixture.debugElement.query(By.css('app-action-btn'));
//this would trigger the update anyway thats why we check for the button not to be disabled
updateBtn.triggerEventHandler('click');
expect(productSettings.update).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,71 @@
import { Component, inject, OnInit } from '@angular/core';
import { SettingsBaseAddEdit } from '../settings-base-add-edit/settings-base-add-edit';
import { ProductFormGroup } from '../../pages/settings/products/product-formgroup';
import { ActionBtn } from '../action-btn/action-btn';
import { TranslatePipe } from '@ngx-translate/core';
import { UpperfirstPipe } from '../../pipes/upperfirst-pipe';
import { ActivatedRoute } from '@angular/router';
import { Observable, take, tap } from 'rxjs';
import { Product } from '../../models/Product';
import { ImageStorage } from '../../services/image-storage';
import { ProductSettings } from '../../services/product-settings';
@Component({
selector: 'app-product-edit',
imports: [ActionBtn, TranslatePipe, UpperfirstPipe],
templateUrl: './../settings-base-add-edit/settings-base-add-edit.html',
styleUrl: './../settings-base-add-edit/settings-base-add-edit.scss',
})
export class ProductEdit extends SettingsBaseAddEdit implements OnInit {
btnText = 'common.update';
title = 'settings.product.edit_product';
readonly form = new ProductFormGroup();
private readonly activatedRoute = inject(ActivatedRoute);
private readonly imageStorage = inject(ImageStorage);
private product?: Product;
private readonly productSettings = inject(ProductSettings);
ngOnInit() {
(<Observable<{ product: Product }>>this.activatedRoute.data)
.pipe(
take(1),
tap(
(data) =>
(this.product = new Product(
data.product.barcode,
data.product.name,
data.product.image,
data.product.id,
)),
),
tap((data) => this.patchForm(data.product)),
)
.subscribe();
}
async patchForm(product: Product) {
try {
this.form.controls.barcode.patchValue(product.barcode);
this.form.controls.name.patchValue(product.name);
if (product.image) {
const imgName = product.image;
const blob = await this.imageStorage.getImage(imgName);
const file = new File([blob], imgName, { type: blob.type });
this.form.controls.image.patchValue(file);
}
} catch (e) {
console.error(e);
//TODO: reportar error
}
}
async submit() {
if (this.product) {
const updatedBarcode = this.form.controls.barcode.value;
const updatedName = this.form.controls.name.value;
const updatedImg = this.form.controls.image.value;
const updatedProduct = new Product(updatedBarcode, updatedName, '', this.product.id);
await this.productSettings.update(this.product, updatedProduct, updatedImg);
}
}
}

View File

@@ -0,0 +1,3 @@
<app-product-add #p>
<app-product-form [form]="p.form"></app-product-form>
</app-product-add>

View File

@@ -0,0 +1,5 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProductAddPage } from './product-add-page';
describe('ProductAddPage', () => {
let component: ProductAddPage;
let fixture: ComponentFixture<ProductAddPage>;
beforeEach(async () => {
TestBed.overrideComponent(ProductAddPage, { set: { template: `` } });
await TestBed.configureTestingModule({
imports: [ProductAddPage],
}).compileComponents();
fixture = TestBed.createComponent(ProductAddPage);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,13 @@
import { Component } from '@angular/core';
import { ProductAdd } from "../../../../components/product-add/product-add";
import { ProductForm } from "../product-form/product-form";
@Component({
selector: 'app-product-add-page',
imports: [ProductAdd, ProductForm],
templateUrl: './product-add-page.html',
styleUrl: './product-add-page.scss',
})
export class ProductAddPage {
}

View File

@@ -0,0 +1,3 @@
<app-product-edit #p>
<app-product-form [form]="p.form"></app-product-form>
</app-product-edit>

View File

@@ -0,0 +1,5 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProductEditPage } from './product-edit-page';
describe('ProductEditPage', () => {
let component: ProductEditPage;
let fixture: ComponentFixture<ProductEditPage>;
beforeEach(async () => {
TestBed.overrideComponent(ProductEditPage, { set: { template: `` } });
await TestBed.configureTestingModule({
imports: [ProductEditPage],
}).compileComponents();
fixture = TestBed.createComponent(ProductEditPage);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,13 @@
import { Component } from '@angular/core';
import { ProductEdit } from "../../../../components/product-edit/product-edit";
import { ProductForm } from "../product-form/product-form";
@Component({
selector: 'app-product-edit-page',
imports: [ProductEdit, ProductForm],
templateUrl: './product-edit-page.html',
styleUrl: './product-edit-page.scss',
})
export class ProductEditPage {
}

View File

@@ -0,0 +1,13 @@
@let form = this.form();
<form [formGroup]="form">
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{'common.name'|translate|upperfirst}}</mat-label>
<input matInput formControlName="name"/>
</mat-form-field>
<app-bar-code-input formControlName="barcode"/>
<app-image-uploader
appImageHandler
[imageControl]="form.controls.image"
[fileIn]="form.controls.image.value"
/>
</form>

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProductForm } from './product-form';
import { provideTranslateService } from '@ngx-translate/core';
describe('ProductForm', () => {
let component: ProductForm;
let fixture: ComponentFixture<ProductForm>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductForm],
providers: [provideTranslateService()]
})
.compileComponents();
fixture = TestBed.createComponent(ProductForm);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,33 @@
import { Component, input } from '@angular/core';
import { ProductFormGroup } from '../product-formgroup';
import { ReactiveFormsModule } from '@angular/forms';
import { MatFormField, MatLabel } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { UpperfirstPipe } from '../../../../pipes/upperfirst-pipe';
import { TranslatePipe } from '@ngx-translate/core';
import { BarCodeInput } from '../../../../components/bar-code-input/bar-code-input';
import { ImageUploader } from '../../../../components/image-uploader/image-uploader';
import { ImageHandler } from '../../../../directives/image-handler';
@Component({
selector: 'app-product-form',
imports: [
MatFormField,
MatInput,
MatLabel,
ReactiveFormsModule,
TranslatePipe,
UpperfirstPipe,
BarCodeInput,
ImageUploader,
ImageHandler
],
templateUrl: './product-form.html',
styleUrl: './product-form.scss',
})
export class ProductForm {
form = input(new ProductFormGroup());
constructor() {
this.form().controls.image.patchValue(new File([], 'asdf'))
}
}

View File

@@ -0,0 +1,11 @@
import { FormControl, FormGroup, Validators } from "@angular/forms";
export class ProductFormGroup extends FormGroup<{barcode: FormControl<string>, name: FormControl<string>, image: FormControl<File|null> }> {
constructor(form = {
barcode: new FormControl('', {validators: [Validators.required], nonNullable: true}),
name: new FormControl('', {validators: [Validators.required], nonNullable: true}),
image: new FormControl<File|null>(null),
}) {
super(form)
}
}

View File

@@ -0,0 +1,49 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProductList } from './product-list';
import { ActivatedRoute, Router } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import { Product } from '../../../../models/Product';
import { By } from '@angular/platform-browser';
describe('ProductList', () => {
let component: ProductList;
let fixture: ComponentFixture<ProductList>;
let activatedRoute: Partial<ActivatedRoute>;
let router: Partial<Router>;
const dataSubject = new BehaviorSubject({ products: [new Product('mock', 'mock', '')] });
const ADD_PATH = ['settings', 'products', 'add'];
beforeEach(async () => {
activatedRoute = {
data: dataSubject,
};
router = {
navigate: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [ProductList],
providers: [
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: Router, useValue: router },
],
}).compileComponents();
fixture = TestBed.createComponent(ProductList);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
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);
});
});

View File

@@ -0,0 +1,41 @@
import { Component, inject } from '@angular/core';
import { SettingsBaseList } from '../../../../components/settings-base-list/settings-base-list';
import { map } from 'rxjs';
import { SimpleListItem } from '../../../../components/simple-list-w-actions/SimpleListItem';
import { ActivatedRoute, Router } from '@angular/router';
import { Product } from '../../../../models/Product';
import { SimpleListItemAction } from '../../../../components/simple-list-w-actions/SimpleListItemAction';
import { SimpleListWActions } from '../../../../components/simple-list-w-actions/simple-list-w-actions';
import { AsyncPipe } from '@angular/common';
import { FloatingBigBtn } from '../../../../components/floating-big-btn/floating-big-btn';
@Component({
selector: 'app-product-list',
imports: [SimpleListWActions, AsyncPipe, FloatingBigBtn],
templateUrl: './../../../../components/settings-base-list/settings-base-list.html',
styleUrl: './../../../../components/settings-base-list/settings-base-list.scss',
})
export class ProductList extends SettingsBaseList {
protected readonly activatedRoute = inject(ActivatedRoute);
private readonly router = inject(Router);
data$ = this.activatedRoute.data.pipe(
map((data) => data['products']),
map((products: Product[]) =>
products.map(
(p) =>
new SimpleListItem(String(p.id), p.name ?? '', [
new SimpleListItemAction('edit', 'edit'),
]),
),
),
);
edit(action: { action: string; subject: string }): void {
this.router.navigate(['settings', 'products', 'edit', action.subject]);
}
add(): void {
this.router.navigate(['settings', 'products', 'add']);
}
}

View File

@@ -0,0 +1,3 @@
<app-simple-layout title="settings.product.products" [withBackBtn]="true" >
<router-outlet></router-outlet>
</app-simple-layout>

View File

@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Products } from './products';
import { provideTranslateService } from '@ngx-translate/core';
describe('Products', () => {
let component: Products;
let fixture: ComponentFixture<Products>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Products],
providers: [provideTranslateService()],
}).compileComponents();
fixture = TestBed.createComponent(Products);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,13 @@
import { Component } from '@angular/core';
import { SimpleLayout } from "../../../components/simple-layout/simple-layout";
import { RouterOutlet } from "@angular/router";
@Component({
selector: 'app-products',
imports: [SimpleLayout, RouterOutlet],
templateUrl: './products.html',
styleUrl: './products.scss',
})
export class Products {
}

View File

@@ -5,6 +5,9 @@ import { chainResolver } from '../../resolvers/chain-resolver';
import { EstablishmentList } from './establishments/establishment-list/establishment-list';
import { establishmentsResolver } from '../../resolvers/establishments-resolver';
import { establishmentResolver } from '../../resolvers/establishment-resolver';
import { ProductList } from './products/product-list/product-list';
import { productsResolver } from '../../resolvers/products-resolver';
import { productResolver } from '../../resolvers/product-resolver';
export const routes: Route[] = [
{
@@ -55,4 +58,24 @@ export const routes: Route[] = [
}
]
},
{
path: 'products',
loadComponent: () => import('./products/products').then( c => c.Products ),
children: [
{
path: '',
component: ProductList,
resolve: {products: productsResolver},
},
{
path: 'add',
loadComponent: () => import('./products/product-add-page/product-add-page').then(c => c.ProductAddPage)
},
{
path: 'edit/:id',
loadComponent: () => import('./products/product-edit-page/product-edit-page').then(c => c.ProductEditPage),
resolve: {product: productResolver}
}
]
},
];

View File

@@ -14,6 +14,6 @@ export class Settings {
new IconNavListItem('translate', 'settings.nav.language', ['languages']),
new IconNavListItem('warehouse', 'settings.nav.manage_chains', ['chains']),
new IconNavListItem('store', 'settings.nav.manage_establishments', ['establishments']),
new IconNavListItem('shopping_bag', 'settings.nav.manage_products', ['/']),
new IconNavListItem('shopping_bag', 'settings.nav.manage_products', ['products']),
];
}

View File

@@ -0,0 +1,74 @@
import { TestBed } from '@angular/core/testing';
import { Location } from '@angular/common';
import { Router } from '@angular/router';
import { ProductSettings } from './product-settings';
import { ImageStorage } from './image-storage';
import { ProductDAO } from '../dao/ProductDAO';
import { Product } from '../models/Product';
const PRODUCT_SETTINGS_PATH = ['settings', 'products'];
describe('ProductSettings', () => {
let service: ProductSettings;
let imageStorage: Partial<ImageStorage>;
let location: Partial<Location>;
let productDAO: Partial<ProductDAO>;
let router: Partial<Router>;
let IMG_MOCK: File;
let PRODUCT_MOCK: Product;
beforeEach(() => {
IMG_MOCK = new File([], 'image.jpg', { type: 'image/jpeg' });
PRODUCT_MOCK = new Product('mock', 'mock', 'mock.jpg', 1);
imageStorage = {
deleteImage: vi.fn(),
saveImage: vi.fn().mockResolvedValue('resolved_mock.jpg'),
};
location = {
replaceState: vi.fn(),
};
productDAO = {
insert: vi.fn(),
update: vi.fn(),
};
router = {
navigate: vi.fn(),
};
TestBed.configureTestingModule({
providers: [
{ provide: ImageStorage, useValue: imageStorage },
{ provide: Location, useValue: location },
{ provide: ProductDAO, useValue: productDAO },
{ provide: Router, useValue: router },
],
});
service = TestBed.inject(ProductSettings);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should save', async () => {
imageStorage.saveImage = vi.fn().mockResolvedValue(IMG_MOCK.name);
await service.save(PRODUCT_MOCK, IMG_MOCK);
expect(imageStorage.saveImage).toHaveBeenCalledExactlyOnceWith(IMG_MOCK);
expect(productDAO.insert).toHaveBeenCalledExactlyOnceWith(PRODUCT_MOCK);
expect(router.navigate).toHaveBeenCalledExactlyOnceWith(PRODUCT_SETTINGS_PATH);
});
it('should update', async () => {
const MODIFIED_IMG_MOCK = new File([], 'new_image.jpg', { type: 'image/jpeg' });
const MODIFIED_PRODUCT_MOCK = {...PRODUCT_MOCK, image: ''};
await service.update(PRODUCT_MOCK, MODIFIED_PRODUCT_MOCK, MODIFIED_IMG_MOCK);
expect(imageStorage.saveImage).toHaveBeenCalledExactlyOnceWith(MODIFIED_IMG_MOCK);
expect(imageStorage.deleteImage).toHaveBeenCalledExactlyOnceWith(PRODUCT_MOCK.image);
expect(MODIFIED_PRODUCT_MOCK.image).toEqual('resolved_mock.jpg');
expect(productDAO.update).toHaveBeenCalledExactlyOnceWith(MODIFIED_PRODUCT_MOCK, {
id: MODIFIED_PRODUCT_MOCK.id,
});
expect(router.navigate).toHaveBeenCalledExactlyOnceWith(PRODUCT_SETTINGS_PATH);
});
});

View File

@@ -0,0 +1,54 @@
import { inject, Injectable } from '@angular/core';
import { Location } from '@angular/common';
import { Router } from '@angular/router';
import { ImageStorage } from './image-storage';
import { ProductDAO } from '../dao/ProductDAO';
import { Product } from '../models/Product';
@Injectable({
providedIn: 'root',
})
export class ProductSettings {
private readonly imageStorage = inject(ImageStorage);
private readonly location = inject(Location);
private readonly productDAO = inject(ProductDAO);
private readonly router = inject(Router);
async save(product: Product, img: File | null) {
try {
if (img) {
const imgFileName = await this.imageStorage.saveImage(img);
product.image = imgFileName;
}
await this.productDAO.insert(product);
} catch (e) {
//catch by message starts with SQLITE_CONSTRAINT_UNIQUE is duplicate
console.error(e)
//TODO: report problem
}
this.router.navigate(['settings', 'products']);
this.location.replaceState('products');
}
async update(prevProd: Product, updatedProd: Product, img: File | null) {
try {
let imgName = prevProd.name;
if (img) {
if (img.name !== imgName) {
imgName = await this.imageStorage.saveImage(img);
if (prevProd.image) {
this.imageStorage.deleteImage(prevProd.image);
}
updatedProd.image = imgName;
}
}
await this.productDAO.update(updatedProd, { id: updatedProd.id });
} catch (e) {
console.error(e);
//TODO: report problem
}
this.router.navigate(['settings', 'products']);
this.location.replaceState('products');
}
}