Compare commits
13 Commits
63679b5087
...
82dd3c607a
| Author | SHA1 | Date | |
|---|---|---|---|
| 82dd3c607a | |||
| f89a27320f | |||
| 564e735b14 | |||
| ec5055e8d6 | |||
| 5050da9936 | |||
| d25b7cb49c | |||
| 9b3e42a161 | |||
| 96e3854945 | |||
| bab5f6fd15 | |||
| 0e8fe172b5 | |||
| 105514423f | |||
| 5d6a11e253 | |||
| 2d4805228a |
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
15
src/app/components/bar-code-input/bar-code-input.html
Normal file
15
src/app/components/bar-code-input/bar-code-input.html
Normal 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>
|
||||||
18
src/app/components/bar-code-input/bar-code-input.scss
Normal file
18
src/app/components/bar-code-input/bar-code-input.scss
Normal 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%;
|
||||||
|
}
|
||||||
65
src/app/components/bar-code-input/bar-code-input.spec.ts
Normal file
65
src/app/components/bar-code-input/bar-code-input.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
60
src/app/components/bar-code-input/bar-code-input.ts
Normal file
60
src/app/components/bar-code-input/bar-code-input.ts
Normal 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 {}
|
||||||
|
}
|
||||||
@@ -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)"
|
||||||
|
|||||||
@@ -3,4 +3,6 @@ barcode-reader {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 999;
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
31
src/app/components/product-add/product-add.spec.ts
Normal file
31
src/app/components/product-add/product-add.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
31
src/app/components/product-add/product-add.ts
Normal file
31
src/app/components/product-add/product-add.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/app/components/product-edit/product-edit.spec.ts
Normal file
74
src/app/components/product-edit/product-edit.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
71
src/app/components/product-edit/product-edit.ts
Normal file
71
src/app/components/product-edit/product-edit.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
36
src/app/directives/image-handler.spec.ts
Normal file
36
src/app/directives/image-handler.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
25
src/app/directives/image-handler.ts
Normal file
25
src/app/directives/image-handler.ts
Normal 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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<app-product-add #p>
|
||||||
|
<app-product-form [form]="p.form"></app-product-form>
|
||||||
|
</app-product-add>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<app-product-edit #p>
|
||||||
|
<app-product-form [form]="p.form"></app-product-form>
|
||||||
|
</app-product-edit>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
33
src/app/pages/settings/products/product-form/product-form.ts
Normal file
33
src/app/pages/settings/products/product-form/product-form.ts
Normal 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'))
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/app/pages/settings/products/product-formgroup.ts
Normal file
11
src/app/pages/settings/products/product-formgroup.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
41
src/app/pages/settings/products/product-list/product-list.ts
Normal file
41
src/app/pages/settings/products/product-list/product-list.ts
Normal 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/app/pages/settings/products/products.html
Normal file
3
src/app/pages/settings/products/products.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<app-simple-layout title="settings.product.products" [withBackBtn]="true" >
|
||||||
|
<router-outlet></router-outlet>
|
||||||
|
</app-simple-layout>
|
||||||
0
src/app/pages/settings/products/products.scss
Normal file
0
src/app/pages/settings/products/products.scss
Normal file
24
src/app/pages/settings/products/products.spec.ts
Normal file
24
src/app/pages/settings/products/products.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
13
src/app/pages/settings/products/products.ts
Normal file
13
src/app/pages/settings/products/products.ts
Normal 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 {
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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']),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
58
src/app/resolvers/product-resolver.spec.ts
Normal file
58
src/app/resolvers/product-resolver.spec.ts
Normal 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}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
25
src/app/resolvers/product-resolver.ts
Normal file
25
src/app/resolvers/product-resolver.ts
Normal 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;
|
||||||
|
};
|
||||||
48
src/app/resolvers/products-resolver.spec.ts
Normal file
48
src/app/resolvers/products-resolver.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
17
src/app/resolvers/products-resolver.ts
Normal file
17
src/app/resolvers/products-resolver.ts
Normal 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;
|
||||||
|
};
|
||||||
74
src/app/services/product-settings.spec.ts
Normal file
74
src/app/services/product-settings.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
54
src/app/services/product-settings.ts
Normal file
54
src/app/services/product-settings.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user