Compare commits

..

14 Commits

48 changed files with 1068 additions and 34 deletions

View File

@@ -4,7 +4,7 @@
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",
"start": "ng serve --host 0.0.0.0 --ssl true --ssl-key \"./cert/server.key\" --ssl-cert \"./cert/server.crt\"", "start": "ng serve --host 0.0.0.0 --ssl true --ssl-key \"./cert/server.key\" --ssl-cert \"./cert/server.crt\"",
"build": "ng build", "build": "env NG_BUILD_MANGLE=false ng build",
"build:dev": "env NG_BUILD_MANGLE=false ng build && scp -P 8022 -r /home/delosrios/programming/groceries-price-tracker/groceries-price-tracker/dist/groceries-price-tracker/browser/** gabriel@192.168.1.4:/data/data/com.termux/files/usr/share/nginx/html/groceries-price-tracker", "build:dev": "env NG_BUILD_MANGLE=false ng build && scp -P 8022 -r /home/delosrios/programming/groceries-price-tracker/groceries-price-tracker/dist/groceries-price-tracker/browser/** gabriel@192.168.1.4:/data/data/com.termux/files/usr/share/nginx/html/groceries-price-tracker",
"watch": "ng build --watch --configuration development", "watch": "ng build --watch --configuration development",
"test": "ng test" "test": "ng test"

View File

@@ -31,10 +31,16 @@
"establishments":"establishments", "establishments":"establishments",
"new_establishment": "new establishment", "new_establishment": "new establishment",
"edit_establishment": "edit establishment" "edit_establishment": "edit establishment"
},
"product": {
"products":"products",
"new_product":"new product",
"edit_product":"edit product"
} }
}, },
"common": { "common": {
"address":"address", "address":"address",
"barcode":"barcode",
"name":"name", "name":"name",
"save": "save", "save": "save",
"update": "update", "update": "update",

View File

@@ -31,10 +31,16 @@
"establishments":"establecimientos", "establishments":"establecimientos",
"new_establishment": "nuevo establecimiento", "new_establishment": "nuevo establecimiento",
"edit_establishment": "editar establecimiento" "edit_establishment": "editar establecimiento"
},
"product": {
"products":"productos",
"new_product":"nuevo producto",
"edit_product":"editar producto"
} }
}, },
"common": { "common": {
"address":"dirección", "address":"dirección",
"barcode":"código de barras",
"name":"nombre", "name":"nombre",
"no_file_yet": "Sin carga", "no_file_yet": "Sin carga",
"save": "guardar", "save": "guardar",

View File

@@ -31,10 +31,16 @@
"establishments":"establishments", "establishments":"establishments",
"new_establishment": "new establishment", "new_establishment": "new establishment",
"edit_establishment": "edit establishment" "edit_establishment": "edit establishment"
},
"product": {
"products":"products",
"new_product":"new product",
"edit_product":"edit product"
} }
}, },
"common": { "common": {
"address":"address", "address":"address",
"barcode":"barcode",
"name":"name", "name":"name",
"save": "save", "save": "save",
"update": "update", "update": "update",

View File

@@ -0,0 +1,15 @@
<div class="bar-code-input__container">
<div class="bar-code-input__input">
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{'common.barcode'|translate|upperfirst}}</mat-label>
<input matInput #input [formControl]="control"/>
</mat-form-field>
</div>
<div class="bar-code-input__btn">
<button matMiniFab color="primary" class="upload-btn">
<mat-icon [fontSet]="'material-symbols-outlined'" (click)="scan.set({scan: true})">barcode_scanner</mat-icon>
</button>
</div>
</div>
<app-bar-code-reader [scan]="scan()" (result)="updateBarcode($event)"></app-bar-code-reader>

View File

@@ -0,0 +1,18 @@
.bar-code-input {
&__container {
display: flex;
column-gap: 8px;
}
&__input {
flex-grow: 1;
}
&__btn {
margin-top: 0.5rem;
}
}
mat-form-field {
width: 100%;
}

View File

@@ -0,0 +1,65 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BarCodeInput } from './bar-code-input';
import { Component } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { provideTranslateService } from '@ngx-translate/core';
import { DetectedBarcode } from '../../types/globalThis';
import { By } from '@angular/platform-browser';
@Component({
selector: 'app-bar-code-input-mock',
imports: [BarCodeInput, ReactiveFormsModule],
template: `
<form [formGroup]="form">
<app-bar-code-input formControlName="mock" />
</form>
`,
styleUrl: './bar-code-input.scss',
})
class BarCodeInputTestbed {
form = new FormGroup({ mock: new FormControl(null) });
}
describe('BarCodeInput', () => {
let component: BarCodeInputTestbed;
let fixture: ComponentFixture<BarCodeInputTestbed>;
let BARCODE_MOCK: {
code: Partial<DetectedBarcode> | null;
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BarCodeInputTestbed],
providers: [provideTranslateService()],
}).compileComponents();
fixture = TestBed.createComponent(BarCodeInputTestbed);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('on result', () => {
it('should patch input value with barcode value ', () => {
const patchValueSpy = vi.spyOn(component.form.controls.mock, 'patchValue');
const markAsDirtySpy = vi.spyOn(component.form.controls.mock, 'markAsDirty');
BARCODE_MOCK = {code: {'rawValue': 'mock'}}
const barcodeReader = fixture.debugElement.query(By.css('app-bar-code-reader'));
barcodeReader.triggerEventHandler('result', new CustomEvent('result', {detail: BARCODE_MOCK}));
expect(patchValueSpy).toHaveBeenCalledExactlyOnceWith(BARCODE_MOCK.code?.rawValue);
expect(markAsDirtySpy).toHaveBeenCalledTimes(1);
});
it('should not patch input value if barcode value is null', () => {
const patchValueSpy = vi.spyOn(component.form.controls.mock, 'patchValue');
BARCODE_MOCK = {code: null}
const barcodeReader = fixture.debugElement.query(By.css('app-bar-code-reader'));
barcodeReader.triggerEventHandler('result', new CustomEvent('result', {detail: BARCODE_MOCK}));
expect(patchValueSpy).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,60 @@
import { Component, OnInit, Optional, Self, signal } from '@angular/core';
import { MatMiniFabButton } from '@angular/material/button';
import { MatFormField, MatLabel } from '@angular/material/form-field';
import { MatIcon } from '@angular/material/icon';
import { MatInput } from '@angular/material/input';
import { BarCodeReaderWrapper } from '../bar-code-reader/bar-code-reader';
import { ControlValueAccessor, FormControl, NgControl, ReactiveFormsModule } from '@angular/forms';
import { UpperfirstPipe } from '../../pipes/upperfirst-pipe';
import { TranslatePipe } from '@ngx-translate/core';
import { DetectedBarcode } from '../../types/globalThis';
@Component({
selector: 'app-bar-code-input',
imports: [
BarCodeReaderWrapper,
MatFormField,
MatIcon,
MatInput,
MatLabel,
MatMiniFabButton,
ReactiveFormsModule,
TranslatePipe,
UpperfirstPipe,
],
templateUrl: './bar-code-input.html',
styleUrl: './bar-code-input.scss',
})
export class BarCodeInput implements ControlValueAccessor, OnInit {
scan = signal({ scan: false });
protected control = new FormControl();
constructor(@Self() @Optional() public controlDir: NgControl) {
if (this.controlDir) {
this.controlDir.valueAccessor = this;
}
}
ngOnInit() {
this.control = <FormControl>this.controlDir?.control;
}
updateBarcode(
barcodeEvent: Event & {
detail?: {
code: DetectedBarcode | null;
};
},
) {
let code = barcodeEvent.detail?.code?.rawValue
if(code) {
this.control.patchValue(code);
this.control.markAsDirty();
}
}
writeValue(obj: any): void {}
registerOnChange(fn: any): void {}
registerOnTouched(fn: any): void {}
setDisabledState?(isDisabled: boolean): void {}
}

View File

@@ -1,4 +1,5 @@
<barcode-reader <barcode-reader
[style.display]="scan().scan ? 'block' : 'none'"
#reader #reader
(result)="this.result.emit($event)" (result)="this.result.emit($event)"
(scan-status)="this.scanStatus.emit($event)" (scan-status)="this.scanStatus.emit($event)"

View File

@@ -3,4 +3,6 @@ barcode-reader {
width: 100%; width: 100%;
position: absolute; position: absolute;
top: 0; top: 0;
left: 0;
z-index: 999;
} }

View File

@@ -1,8 +1,3 @@
@if (imgUrl()) {
<div class="image-preview__container">
<img [src]="imgUrl()" [alt]="fileName()" width="300" height="300" />
</div>
}
<input <input
(change)="onFileSelected($event)" (change)="onFileSelected($event)"
#fileUpload #fileUpload
@@ -14,7 +9,12 @@
<div class="file-upload__description" title="{{ fileName() }}"> <div class="file-upload__description" title="{{ fileName() }}">
{{ fileName() || 'common.no_file_yet' | translate | upperfirst }} {{ fileName() || 'common.no_file_yet' | translate | upperfirst }}
</div> </div>
<button matMiniFab color="primary" class="upload-btn" (click)="fileUpload.click()"> <button matMiniFab color="primary" (click)="fileUpload.click()">
<mat-icon>attach_file</mat-icon> <mat-icon>attach_file</mat-icon>
</button> </button>
</div> </div>
@if (imgUrl()) {
<div class="image-preview__container">
<img [src]="imgUrl()" [alt]="fileName()" width="300" height="300" />
</div>
}

View File

@@ -3,6 +3,7 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
margin-bottom: 16px; margin-bottom: 16px;
margin-top: 16px;
} }
} }

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

@@ -11,11 +11,11 @@ export class ProductDAO extends BaseDAO<Product, DBProduct, DBProduct> {
super(Product.name); super(Product.name);
} }
protected override toDB(model: Product): DBProduct { protected override toDB(model: Product) {
return new Product(model.barcode, model.name, model.image, model.id); return new DBProduct(model.barcode, model.name, model.image, model.id);
} }
protected override fromDB(qR: DBProduct): Product { protected override fromDB(qR: DBProduct) {
return new DBProduct(qR.barcode, qR.name, qR.image, qR.id); return new Product(qR.barcode, qR.name, qR.image, qR.id);
} }
} }

View File

@@ -0,0 +1,36 @@
import { Component } from '@angular/core';
import { ImageHandler } from './image-handler';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-image-handler-mock',
imports: [ImageHandler, ReactiveFormsModule],
template: `
<form [formGroup]="form">
<div appImageHandler [imageControl]="form.controls.image"></div>
</form>
`,
styles: [],
})
class ImageHandlerTestBed {
form = new FormGroup({ image: new FormControl(null) });
}
describe('ImageHandler', () => {
let component: ImageHandlerTestBed;
let fixture: ComponentFixture<ImageHandlerTestBed>;
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [ImageHandlerTestBed],
}).compileComponents();
fixture = TestBed.createComponent(ImageHandlerTestBed);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,25 @@
import { ChangeDetectorRef, Directive, HostListener, inject, input, OnInit } from '@angular/core';
import { FormControl } from '@angular/forms';
import { take } from 'rxjs';
//TODO: maybe this could be somehow integrated into the file input using ControlValueAccesor
@Directive({
selector: '[appImageHandler]',
})
export class ImageHandler implements OnInit {
@HostListener('file', ['$event' as any]) updateFileImage(file: File | null) {
const control = this.imageControl();
control.patchValue(file);
control.markAsDirty();
}
imageControl = input<FormControl<File | null>>(new FormControl(null));
private readonly cd = inject(ChangeDetectorRef);
ngOnInit() {
this.imageControl()
.valueChanges.pipe(take(1))
.subscribe({
next: () => this.cd.markForCheck(),
});
}
}

View File

@@ -4,5 +4,9 @@
<mat-label>{{'common.name'|translate|upperfirst}}</mat-label> <mat-label>{{'common.name'|translate|upperfirst}}</mat-label>
<input matInput formControlName="name"/> <input matInput formControlName="name"/>
</mat-form-field> </mat-form-field>
<app-image-uploader (file)="updateFileImage($event)" [fileIn]="form.controls.image.value"></app-image-uploader> <app-image-uploader
appImageHandler
[imageControl]="form.controls.image"
[fileIn]="form.controls.image.value"
/>
</form> </form>

View File

@@ -1,4 +1,4 @@
import { ChangeDetectorRef, Component, inject, input, OnDestroy, OnInit } from '@angular/core'; import { Component, input } from '@angular/core';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { ImageUploader } from '../../../../components/image-uploader/image-uploader'; import { ImageUploader } from '../../../../components/image-uploader/image-uploader';
@@ -6,11 +6,12 @@ import { ReactiveFormsModule } from '@angular/forms';
import { ChainFormGroup } from '../chain-formgroup'; import { ChainFormGroup } from '../chain-formgroup';
import { TranslatePipe } from '@ngx-translate/core'; import { TranslatePipe } from '@ngx-translate/core';
import { UpperfirstPipe } from '../../../../pipes/upperfirst-pipe'; import { UpperfirstPipe } from '../../../../pipes/upperfirst-pipe';
import { Subscription } from 'rxjs'; import { ImageHandler } from '../../../../directives/image-handler';
@Component({ @Component({
selector: 'app-chain-form', selector: 'app-chain-form',
imports: [ imports: [
ImageHandler,
ImageUploader, ImageUploader,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
@@ -21,24 +22,6 @@ import { Subscription } from 'rxjs';
templateUrl: './chain-form.html', templateUrl: './chain-form.html',
styles: ``, styles: ``,
}) })
export class ChainForm implements OnInit, OnDestroy { export class ChainForm {
form = input(new ChainFormGroup()); form = input(new ChainFormGroup());
private readonly cd = inject(ChangeDetectorRef);
private imageSubscription?: Subscription;
ngOnInit() {
this.imageSubscription = this.form().controls.image.valueChanges.subscribe({
next: () => this.cd.detectChanges(),
});
}
updateFileImage(file: File) {
const form = this.form();
form.controls.image.patchValue(file);
form.controls.image.markAsDirty();
}
ngOnDestroy() {
this.imageSubscription?.unsubscribe();
}
} }

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 { EstablishmentList } from './establishments/establishment-list/establishment-list';
import { establishmentsResolver } from '../../resolvers/establishments-resolver'; import { establishmentsResolver } from '../../resolvers/establishments-resolver';
import { establishmentResolver } from '../../resolvers/establishment-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[] = [ 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('translate', 'settings.nav.language', ['languages']),
new IconNavListItem('warehouse', 'settings.nav.manage_chains', ['chains']), new IconNavListItem('warehouse', 'settings.nav.manage_chains', ['chains']),
new IconNavListItem('store', 'settings.nav.manage_establishments', ['establishments']), 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,58 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, RedirectCommand, ResolveFn, Router } from '@angular/router';
import { productResolver } from './product-resolver';
import { Product } from '../models/Product';
import { ProductDAO } from '../dao/ProductDAO';
import { Component } from '@angular/core';
@Component({
selector: 'app-mock',
template: ``,
styles: ``,
})
class MockComponent {}
describe('productResolver', () => {
const executeResolver: ResolveFn<Product | RedirectCommand> = (...resolverParameters) =>
TestBed.runInInjectionContext(() => productResolver(...resolverParameters));
let productDAO: Partial<ProductDAO>;
let router: Router;
let PRODUCT_MOCK: Product;
beforeEach(() => {
productDAO = {
findBy: vi.fn().mockResolvedValue([]),
};
PRODUCT_MOCK = new Product('mock','mock','',1);
TestBed.configureTestingModule({
providers: [
provideRouter([
{ path: 'mock/:id', component: MockComponent, resolve: { product: productResolver } },
{ path: 'settings/products', component: MockComponent },
]),
{ provide: ProductDAO, useValue: productDAO },
],
});
router = TestBed.inject(Router);
});
it('should be created', () => {
expect(executeResolver).toBeTruthy();
});
it('should navigate back to settings for not found ids', async () => {
await router.navigate(['mock', '99']);
expect(router.url).toEqual('/settings/products');
});
it('should complete navigation for found ids', async () => {
productDAO.findBy = vi.fn().mockResolvedValue([PRODUCT_MOCK]);
await router.navigate(['mock', String(PRODUCT_MOCK.id)]);
expect(router.url).toEqual(`/mock/${PRODUCT_MOCK.id}`);
});
});

View File

@@ -0,0 +1,25 @@
import { inject } from '@angular/core';
import { RedirectCommand, ResolveFn, Router } from '@angular/router';
import { ProductDAO } from '../dao/ProductDAO';
import { Product } from '../models/Product';
export const productResolver: ResolveFn<Product|RedirectCommand> = async (route, _) => {
const productDAO = inject(ProductDAO);
const router = inject(Router);
const chainID = (<{ id: string }>route.params).id;
let product: Product;
try {
const results = await productDAO.findBy({ id: Number(chainID) });
if (!results[0]) {
throw new Error('The search for chain on edit did not find any match');
}
product = results[0];
} catch (e) {
console.error(e);
return new RedirectCommand(router.parseUrl('settings/products'), {
skipLocationChange: true,
});
}
return product;
};

View File

@@ -0,0 +1,48 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, ResolveFn, Router } from '@angular/router';
import { productsResolver } from './products-resolver';
import { Product } from '../models/Product';
import { ProductDAO } from '../dao/ProductDAO';
import { Component } from '@angular/core';
@Component({
selector: 'app-mock',
template: ``,
styles: ``,
})
class MockComponent {}
describe('productsResolver', () => {
const executeResolver: ResolveFn<Product[]> = (...resolverParameters) =>
TestBed.runInInjectionContext(() => productsResolver(...resolverParameters));
let productDAO: Partial<ProductDAO>;
let router: Router;
beforeEach(() => {
productDAO = {
findAll: vi.fn().mockResolvedValue([]),
};
TestBed.configureTestingModule({
providers: [
provideRouter([
{ path: 'products', component: MockComponent, resolve: { products: productsResolver } },
]),
{ provide: ProductDAO, useValue: productDAO },
],
});
router = TestBed.inject(Router);
});
it('should be created', () => {
expect(executeResolver).toBeTruthy();
});
it('should call productDAO findAll method', async () => {
await router.navigate(['products']);
expect(productDAO.findAll).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,17 @@
import { ResolveFn } from '@angular/router';
import { ProductDAO } from '../dao/ProductDAO';
import { inject } from '@angular/core';
import { Product } from '../models/Product';
export const productsResolver: ResolveFn<Product[]> = async (route, state) => {
const productDAO = inject(ProductDAO);
let products: Product[] = [];
try {
products = await productDAO.findAll()
} catch(e) {
console.error(e);
//TODO: report error
}
return 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');
}
}