Compare commits

..

52 Commits

Author SHA1 Message Date
63679b5087 refactor: not to drop all tables on app init 2026-02-17 19:53:17 -03:00
eef3dbc2fb feat: add establishment settings 2026-02-17 19:47:25 -03:00
816252308c refactor: chain list to extend base settings list 2026-02-17 19:32:36 -03:00
36df70bf38 feat: add base class for settings listing 2026-02-17 19:27:45 -03:00
91c09a0ca3 refactor: edit chain to extend settings base add edit class 2026-02-17 18:48:46 -03:00
0d49ee6dd8 refactor: trigger cd on image valueChanges 2026-02-17 18:42:57 -03:00
3c87de3d51 refactor: extend base settings add class to edit 2026-02-17 01:36:12 -03:00
736a658323 refactor: base members to be abstract as default values are senseless 2026-02-16 22:38:48 -03:00
f140ef403b refactor: add chain to extend settings base add 2026-02-16 22:28:00 -03:00
13b19d5776 feat: add base class for settings add screen 2026-02-16 21:46:25 -03:00
4e53684649 refactor: remove unneeded imports 2026-02-16 17:43:21 -03:00
836c6652ec refactor: always navigate, rm edit/add scren from history stack 2026-02-16 17:25:58 -03:00
7de993a765 refactor: add service for chains settings 2026-02-14 00:28:41 -03:00
6babbea1c4 feat: add chain select cmp 2026-02-11 21:19:55 -03:00
5d4c0c5d36 refactor: wip app 2026-02-08 00:00:20 -03:00
3ee45adaa6 feat: add home page 2026-02-07 23:59:26 -03:00
267f660512 feat: add routing for settings 2026-02-07 23:55:30 -03:00
454f93fb11 feat: add settings page 2026-02-07 23:52:02 -03:00
e7c65dd268 feat: add chain management components 2026-02-07 23:47:12 -03:00
73bf96f1ef feat: resolvers for chain edit and list 2026-02-07 23:43:12 -03:00
e827a14284 feat: chains form 2026-02-07 23:40:47 -03:00
1146bf6f8d feat: chain form group 2026-02-07 23:32:23 -03:00
d4723e6a24 refactor: await rejections on tests 2026-02-07 23:27:20 -03:00
b07bc1db30 feat: language selection 2026-02-07 23:24:02 -03:00
010646da8a feat: action btn 2026-02-07 23:17:53 -03:00
9b62766c66 chore: assets for language selection 2026-02-07 23:05:27 -03:00
2c172dd3d1 feat: add custom file input for images 2026-02-07 23:00:12 -03:00
e803c670f4 feat: service to store images with OPFS 2026-02-06 21:41:20 -03:00
c0a3da4635 feat: add utility service for handling file types 2026-02-05 22:45:46 -03:00
2c332c6758 fix: update test to work with native browser 2026-02-04 21:47:22 -03:00
ea2779c681 fix: add coverage to vitest config 2026-02-04 21:41:45 -03:00
f89fe2e323 test: switch runner to browser mode
- Run tests in native browser instead of using jsdom
2026-02-04 21:25:34 -03:00
1502418cfa feat: add origin private file system service 2026-02-03 22:28:21 -03:00
1bf0b71ca7 fix: log error as error 2026-02-01 20:42:38 -03:00
d44ba2b421 chore(i18n): extends dictionaries 2026-01-31 22:58:12 -03:00
f06d5396ad style: add utility classes, apply borderbox to all elements 2026-01-31 22:35:24 -03:00
2e77ab3e40 style: add outlined icons font 2026-01-31 22:34:03 -03:00
9a3dd87896 build: prevent mangling (minifying classes and functions names) on build 2026-01-31 22:30:56 -03:00
2e6d0c1f0a refactor: change titlecase to upperfirst for capitalizing 2026-01-31 22:29:03 -03:00
6615912a35 feat: add pipe that capitalize first word only 2026-01-31 22:25:32 -03:00
1723ea7a39 refactor: height 100% and back btn 2026-01-31 11:30:15 -03:00
ae249ea828 feat: add list with actions component 2026-01-29 20:57:26 -03:00
4397e5fec3 refactor: moves positioning to host 2026-01-26 23:45:37 -03:00
f34ffec5b0 fix: bigClick to emit on click 2026-01-26 23:01:52 -03:00
a4aba009b8 fix: properly aligns button 2026-01-26 22:54:35 -03:00
7208a08ffb feat: add floating btn 2026-01-26 21:08:14 -03:00
85da313957 feat: add simplet layout for pages 2026-01-25 16:03:05 -03:00
3774750a56 feat: add some listing components 2026-01-25 16:02:33 -03:00
68866f4725 feat: navigation bar 2026-01-24 19:26:29 -03:00
00209ce137 build: add i18n support 2026-01-20 22:54:08 -03:00
490b929fa9 refactor: clean app component 2026-01-20 20:42:11 -03:00
94eb9b1bf8 build: ad material 2026-01-20 20:29:50 -03:00
143 changed files with 3505 additions and 359 deletions

View File

@@ -48,8 +48,8 @@
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
"maximumWarning": "2MB",
"maximumError": "4MB"
},
{
"type": "anyComponentStyle",
@@ -89,6 +89,7 @@
"test": {
"builder": "@angular/build:unit-test",
"options": {
"runnerConfig": "vitest-base.config.ts",
"coverageExclude": ["**/*.html"]
}
}

212
package-lock.json generated
View File

@@ -8,13 +8,17 @@
"name": "groceries-price-tracker",
"version": "0.0.0",
"dependencies": {
"@angular/cdk": "^21.1.0",
"@angular/common": "^21.0.0",
"@angular/compiler": "^21.0.0",
"@angular/core": "^21.0.0",
"@angular/forms": "^21.0.0",
"@angular/material": "^21.1.0",
"@angular/platform-browser": "^21.0.0",
"@angular/router": "^21.0.0",
"@angular/service-worker": "^21.0.6",
"@ngx-translate/core": "^17.0.0",
"@ngx-translate/http-loader": "^17.0.0",
"angular-web-sqlite": "^1.0.34",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
@@ -23,6 +27,7 @@
"@angular/build": "^21.0.4",
"@angular/cli": "^21.0.4",
"@angular/compiler-cli": "^21.0.0",
"@vitest/browser-playwright": "^4.0.17",
"@vitest/coverage-v8": "^4.0.17",
"jsdom": "^27.1.0",
"typescript": "~5.9.2",
@@ -422,6 +427,22 @@
}
}
},
"node_modules/@angular/cdk": {
"version": "21.1.0",
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.1.0.tgz",
"integrity": "sha512-zvV37HPKhtu0bOfuK0IhjKKq++Xb57Z11uZYZJI34BZnZ5y1TPhJpcmrHhjb2uKUNfDvePUqhlnIlKAXHSBIhw==",
"license": "MIT",
"dependencies": {
"parse5": "^8.0.0",
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": "^21.0.0 || ^22.0.0",
"@angular/core": "^21.0.0 || ^22.0.0",
"@angular/platform-browser": "^21.0.0 || ^22.0.0",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/cli": {
"version": "21.0.4",
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.0.4.tgz",
@@ -563,6 +584,23 @@
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/material": {
"version": "21.1.0",
"resolved": "https://registry.npmjs.org/@angular/material/-/material-21.1.0.tgz",
"integrity": "sha512-VFWUQMU5Rm8w6uW5+FcMbsDvHMmhviVxPsKAFdinJ4ySbm5c6z9c64nhlYCNRswRgLB1VcoVxEWitP77LUagYg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/cdk": "21.1.0",
"@angular/common": "^21.0.0 || ^22.0.0",
"@angular/core": "^21.0.0 || ^22.0.0",
"@angular/forms": "^21.0.0 || ^22.0.0",
"@angular/platform-browser": "^21.0.0 || ^22.0.0",
"rxjs": "^6.5.3 || ^7.4.0"
}
},
"node_modules/@angular/platform-browser": {
"version": "21.0.6",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.0.6.tgz",
@@ -2663,6 +2701,32 @@
"@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@ngx-translate/core": {
"version": "17.0.0",
"resolved": "https://registry.npmjs.org/@ngx-translate/core/-/core-17.0.0.tgz",
"integrity": "sha512-Rft2D5ns2pq4orLZjEtx1uhNuEBerUdpFUG1IcqtGuipj6SavgB8SkxtNQALNDA+EVlvsNCCjC2ewZVtUeN6rg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": ">=16",
"@angular/core": ">=16"
}
},
"node_modules/@ngx-translate/http-loader": {
"version": "17.0.0",
"resolved": "https://registry.npmjs.org/@ngx-translate/http-loader/-/http-loader-17.0.0.tgz",
"integrity": "sha512-hgS8sa0ARjH9ll3PhkLTufeVXNI2DNR2uFKDhBgq13siUXzzVr/a31M6zgecrtwbA34iaBV01hsTMbMS8V7iIw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": ">=16",
"@angular/core": ">=16"
}
},
"node_modules/@npmcli/agent": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.0.tgz",
@@ -3306,6 +3370,13 @@
"license": "MIT",
"optional": true
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"dev": true,
"license": "MIT"
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-beta.47",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.47.tgz",
@@ -4078,6 +4149,63 @@
"vite": "^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/browser": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.0.17.tgz",
"integrity": "sha512-cgf2JZk2fv5or3efmOrRJe1V9Md89BPgz4ntzbf84yAb+z2hW6niaGFinl9aFzPZ1q3TGfWZQWZ9gXTFThs2Qw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/mocker": "4.0.17",
"@vitest/utils": "4.0.17",
"magic-string": "^0.30.21",
"pixelmatch": "7.1.0",
"pngjs": "^7.0.0",
"sirv": "^3.0.2",
"tinyrainbow": "^3.0.3",
"ws": "^8.18.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "4.0.17"
}
},
"node_modules/@vitest/browser-playwright": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@vitest/browser-playwright/-/browser-playwright-4.0.17.tgz",
"integrity": "sha512-CE9nlzslHX6Qz//MVrjpulTC9IgtXTbJ+q7Rx1HD+IeSOWv4NHIRNHPA6dB4x01d9paEqt+TvoqZfmgq40DxEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/browser": "4.0.17",
"@vitest/mocker": "4.0.17",
"tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"playwright": "*",
"vitest": "4.0.17"
},
"peerDependenciesMeta": {
"playwright": {
"optional": false
}
}
},
"node_modules/@vitest/browser/node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.0.17",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.17.tgz",
@@ -7278,7 +7406,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz",
"integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
@@ -7332,7 +7459,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
@@ -7446,6 +7572,19 @@
"@napi-rs/nice": "^1.0.4"
}
},
"node_modules/pixelmatch": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz",
"integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==",
"dev": true,
"license": "ISC",
"dependencies": {
"pngjs": "^7.0.0"
},
"bin": {
"pixelmatch": "bin/pixelmatch"
}
},
"node_modules/pkce-challenge": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
@@ -7456,6 +7595,50 @@
"node": ">=16.20.0"
}
},
"node_modules/playwright": {
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.1.tgz",
"integrity": "sha512-+2uTZHxSCcxjvGc5C891LrS1/NlxglGxzrC4seZiVjcYVQfUa87wBL6rTDqzGjuoWNjnBzRqKmF6zRYGMvQUaQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"playwright-core": "1.58.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.1.tgz",
"integrity": "sha512-bcWzOaTxcW+VOOGBCQgnaKToLJ65d6AqfLVKEWvexyS3AS6rbXl+xdpYRMGSRBClPvyj44njOWoxjNdL/H9UNg==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.19.0"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -8014,6 +8197,21 @@
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@polka/url": "^1.0.0-next.24",
"mrmime": "^2.0.0",
"totalist": "^3.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/slice-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
@@ -8380,6 +8578,16 @@
"node": ">=0.6"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/tough-cookie": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",

View File

@@ -5,6 +5,7 @@
"ng": "ng",
"start": "ng serve --host 0.0.0.0 --ssl true --ssl-key \"./cert/server.key\" --ssl-cert \"./cert/server.crt\"",
"build": "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",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
@@ -23,13 +24,17 @@
"private": true,
"packageManager": "npm@11.5.1",
"dependencies": {
"@angular/cdk": "^21.1.0",
"@angular/common": "^21.0.0",
"@angular/compiler": "^21.0.0",
"@angular/core": "^21.0.0",
"@angular/forms": "^21.0.0",
"@angular/material": "^21.1.0",
"@angular/platform-browser": "^21.0.0",
"@angular/router": "^21.0.0",
"@angular/service-worker": "^21.0.6",
"@ngx-translate/core": "^17.0.0",
"@ngx-translate/http-loader": "^17.0.0",
"angular-web-sqlite": "^1.0.34",
"rxjs": "~7.8.0",
"tslib": "^2.3.0"
@@ -38,6 +43,7 @@
"@angular/build": "^21.0.4",
"@angular/cli": "^21.0.4",
"@angular/compiler-cli": "^21.0.0",
"@vitest/browser-playwright": "^4.0.17",
"@vitest/coverage-v8": "^4.0.17",
"jsdom": "^27.1.0",
"typescript": "~5.9.2",

43
public/i18n/en.json Normal file
View File

@@ -0,0 +1,43 @@
{
"navbar": {
"label": {
"home": "home",
"register": "register",
"settings": "settings",
"budget": "budget"
}
},
"settings": {
"title": "settings",
"nav": {
"language": "language",
"manage_chains": "manage chains",
"manage_establishments": "manage establishments",
"manage_products": "manage products"
},
"language": {
"title": "language",
"english": "english",
"spanish": "spanish",
"portuguese": "portuguese"
},
"chain": {
"chain": "chain",
"chains": "chains",
"new_chain": "new chain",
"edit_chain": "edit chain"
},
"establishment": {
"establishments":"establishments",
"new_establishment": "new establishment",
"edit_establishment": "edit establishment"
}
},
"common": {
"address":"address",
"name":"name",
"save": "save",
"update": "update",
"no_file_yet": "no file upload yet"
}
}

43
public/i18n/es.json Normal file
View File

@@ -0,0 +1,43 @@
{
"navbar": {
"label": {
"home": "inicio",
"register": "ingresar",
"settings": "ajustes",
"budget": "presupuesto"
}
},
"settings": {
"title": "ajustes",
"nav": {
"language": "idioma",
"manage_chains": "administrar cadenas",
"manage_establishments": "administrar establecimientos",
"manage_products": "administrar productos"
},
"language": {
"title": "idioma",
"english": "inglés",
"spanish": "español",
"portuguese": "portugués"
},
"chain": {
"chain": "cadena",
"chains": "cadenas",
"new_chain": "nueva cadena",
"edit_chain": "editar cadena"
},
"establishment": {
"establishments":"establecimientos",
"new_establishment": "nuevo establecimiento",
"edit_establishment": "editar establecimiento"
}
},
"common": {
"address":"dirección",
"name":"nombre",
"no_file_yet": "Sin carga",
"save": "guardar",
"update": "actualizar"
}
}

43
public/i18n/pt.json Normal file
View File

@@ -0,0 +1,43 @@
{
"navbar": {
"label": {
"home": "home",
"register": "register",
"settings": "settings",
"budget": "budget"
}
},
"settings": {
"title": "settings",
"nav": {
"language": "language",
"manage_chains": "manage chains",
"manage_establishments": "manage establishments",
"manage_products": "manage products"
},
"language": {
"title": "language",
"english": "english",
"spanish": "spanish",
"portuguese": "portuguese"
},
"chain": {
"chain": "chain",
"chains": "chains",
"new_chain": "new chain",
"edit_chain": "edit chain"
},
"establishment": {
"establishments":"establishments",
"new_establishment": "new establishment",
"edit_establishment": "edit establishment"
}
},
"common": {
"address":"address",
"name":"name",
"save": "save",
"update": "update",
"no_file_yet": "no file upload yet"
}
}

BIN
public/icons/flags/br.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/icons/flags/sp.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 582 B

BIN
public/icons/flags/us.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 B

View File

@@ -11,11 +11,16 @@ import { WebSqlite } from 'angular-web-sqlite';
import { Sqlite } from './services/sqlite';
import { tables } from '../migrations/20260117';
import { provideServiceWorker } from '@angular/service-worker';
import { provideTranslateService, TranslateService } from '@ngx-translate/core';
import { provideTranslateHttpLoader } from '@ngx-translate/http-loader';
export const appConfig: ApplicationConfig = {
providers: [
provideAppInitializer(async () => {
const sqlite = inject(Sqlite);
const translateService = inject(TranslateService);
translateService.addLangs(['en', 'es', 'pt']);
translateService.setFallbackLang('es');
await sqlite.initializeDatabase('gptdb');
await sqlite.batchSqlOperations(tables);
await sqlite.executeQuery('PRAGMA foreign_keys = ON;');
@@ -28,5 +33,13 @@ export const appConfig: ApplicationConfig = {
enabled: !isDevMode(),
registrationStrategy: 'registerWhenStable:30000',
}),
provideTranslateService({
loader: provideTranslateHttpLoader({
prefix: '/i18n/',
suffix: '.json',
}),
fallbackLang: 'en',
lang: 'en',
}),
],
};

View File

@@ -1,342 +1,4 @@
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
<!-- * * * * * * * to get started with your project! * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<style>
:host {
--bright-blue: oklch(51.01% 0.274 263.83);
--electric-violet: oklch(53.18% 0.28 296.97);
--french-violet: oklch(47.66% 0.246 305.88);
--vivid-pink: oklch(69.02% 0.277 332.77);
--hot-red: oklch(61.42% 0.238 15.34);
--orange-red: oklch(63.32% 0.24 31.68);
--gray-900: oklch(19.37% 0.006 300.98);
--gray-700: oklch(36.98% 0.014 302.71);
--gray-400: oklch(70.9% 0.015 304.04);
--red-to-pink-to-purple-vertical-gradient: linear-gradient(
180deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--red-to-pink-to-purple-horizontal-gradient: linear-gradient(
90deg,
var(--orange-red) 0%,
var(--vivid-pink) 50%,
var(--electric-violet) 100%
);
--pill-accent: var(--bright-blue);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1 {
font-size: 3.125rem;
color: var(--gray-900);
font-weight: 500;
line-height: 100%;
letter-spacing: -0.125rem;
margin: 0;
font-family: "Inter Tight", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol";
}
p {
margin: 0;
color: var(--gray-700);
}
main {
width: 100%;
min-height: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 1rem;
box-sizing: inherit;
position: relative;
}
.angular-logo {
max-width: 9.2rem;
}
.content {
display: flex;
justify-content: space-around;
width: 100%;
max-width: 700px;
margin-bottom: 3rem;
}
.content h1 {
margin-top: 1.75rem;
}
.content p {
margin-top: 1.5rem;
}
.divider {
width: 1px;
background: var(--red-to-pink-to-purple-vertical-gradient);
margin-inline: 0.5rem;
}
.pill-group {
display: flex;
flex-direction: column;
align-items: start;
flex-wrap: wrap;
gap: 1.25rem;
}
.pill {
display: flex;
align-items: center;
--pill-accent: var(--bright-blue);
background: color-mix(in srgb, var(--pill-accent) 5%, transparent);
color: var(--pill-accent);
padding-inline: 0.75rem;
padding-block: 0.375rem;
border-radius: 2.75rem;
border: 0;
transition: background 0.3s ease;
font-family: var(--inter-font);
font-size: 0.875rem;
font-style: normal;
font-weight: 500;
line-height: 1.4rem;
letter-spacing: -0.00875rem;
text-decoration: none;
white-space: nowrap;
}
.pill:hover {
background: color-mix(in srgb, var(--pill-accent) 15%, transparent);
}
.pill-group .pill:nth-child(6n + 1) {
--pill-accent: var(--bright-blue);
}
.pill-group .pill:nth-child(6n + 2) {
--pill-accent: var(--electric-violet);
}
.pill-group .pill:nth-child(6n + 3) {
--pill-accent: var(--french-violet);
}
.pill-group .pill:nth-child(6n + 4),
.pill-group .pill:nth-child(6n + 5),
.pill-group .pill:nth-child(6n + 6) {
--pill-accent: var(--hot-red);
}
.pill-group svg {
margin-inline-start: 0.25rem;
}
.social-links {
display: flex;
align-items: center;
gap: 0.73rem;
margin-top: 1.5rem;
}
.social-links path {
transition: fill 0.3s ease;
fill: var(--gray-400);
}
.social-links a:hover svg path {
fill: var(--gray-900);
}
@media screen and (max-width: 650px) {
.content {
flex-direction: column;
width: max-content;
}
.divider {
height: 1px;
width: 100%;
background: var(--red-to-pink-to-purple-horizontal-gradient);
margin-block: 1.5rem;
}
}
</style>
<main class="main">
<div class="content">
<div class="left-side">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 982 239"
fill="none"
class="angular-logo"
>
<g clip-path="url(#a)">
<path
fill="url(#b)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
<path
fill="url(#c)"
d="M388.676 191.625h30.849L363.31 31.828h-35.758l-56.215 159.797h30.848l13.174-39.356h60.061l13.256 39.356Zm-65.461-62.675 21.602-64.311h1.227l21.602 64.311h-44.431Zm126.831-7.527v70.202h-28.23V71.839h27.002v20.374h1.392c2.782-6.71 7.2-12.028 13.255-15.956 6.056-3.927 13.584-5.89 22.503-5.89 8.264 0 15.465 1.8 21.684 5.318 6.137 3.518 10.964 8.673 14.319 15.382 3.437 6.71 5.074 14.81 4.992 24.383v76.175h-28.23v-71.92c0-8.019-2.046-14.237-6.219-18.819-4.173-4.5-9.819-6.791-17.102-6.791-4.91 0-9.328 1.063-13.174 3.272-3.846 2.128-6.792 5.237-9.001 9.328-2.046 4.009-3.191 8.918-3.191 14.728ZM589.233 239c-10.147 0-18.82-1.391-26.103-4.091-7.282-2.7-13.092-6.382-17.511-10.964-4.418-4.582-7.528-9.655-9.164-15.219l25.448-6.136c1.145 2.372 2.782 4.663 4.991 6.954 2.209 2.291 5.155 4.255 8.837 5.81 3.683 1.554 8.428 2.291 14.074 2.291 8.019 0 14.647-1.964 19.884-5.81 5.237-3.845 7.856-10.227 7.856-19.064v-22.665h-1.391c-1.473 2.946-3.601 5.892-6.383 9.001-2.782 3.109-6.464 5.645-10.965 7.691-4.582 2.046-10.228 3.109-17.101 3.109-9.165 0-17.511-2.209-25.039-6.545-7.446-4.337-13.42-10.883-17.757-19.474-4.418-8.673-6.628-19.473-6.628-32.565 0-13.091 2.21-24.301 6.628-33.383 4.419-9.082 10.311-15.955 17.839-20.7 7.528-4.746 15.874-7.037 25.039-7.037 7.037 0 12.846 1.145 17.347 3.518 4.582 2.373 8.182 5.236 10.883 8.51 2.7 3.272 4.746 6.382 6.137 9.327h1.554v-19.8h27.821v121.749c0 10.228-2.454 18.737-7.364 25.447-4.91 6.709-11.538 11.7-20.048 15.055-8.509 3.355-18.165 4.991-28.884 4.991Zm.245-71.266c5.974 0 11.047-1.473 15.302-4.337 4.173-2.945 7.446-7.118 9.573-12.519 2.21-5.482 3.274-12.027 3.274-19.637 0-7.609-1.064-14.155-3.274-19.8-2.127-5.646-5.318-10.064-9.491-13.255-4.174-3.11-9.329-4.746-15.384-4.746s-11.537 1.636-15.792 4.91c-4.173 3.272-7.365 7.772-9.492 13.418-2.128 5.727-3.191 12.191-3.191 19.392 0 7.2 1.063 13.745 3.273 19.228 2.127 5.482 5.318 9.736 9.573 12.764 4.174 3.027 9.41 4.582 15.629 4.582Zm141.56-26.51V71.839h28.23v119.786h-27.412v-21.273h-1.227c-2.7 6.709-7.119 12.191-13.338 16.446-6.137 4.255-13.747 6.382-22.748 6.382-7.855 0-14.81-1.718-20.783-5.237-5.974-3.518-10.72-8.591-14.075-15.382-3.355-6.709-5.073-14.891-5.073-24.464V71.839h28.312v71.921c0 7.609 2.046 13.664 6.219 18.083 4.173 4.5 9.655 6.709 16.365 6.709 4.173 0 8.183-.982 12.111-3.028 3.927-2.045 7.118-5.072 9.655-9.082 2.537-4.091 3.764-9.164 3.764-15.218Zm65.707-109.395v159.796h-28.23V31.828h28.23Zm44.841 162.169c-7.61 0-14.402-1.391-20.457-4.091-6.055-2.7-10.883-6.791-14.32-12.109-3.518-5.319-5.237-11.946-5.237-19.801 0-6.791 1.228-12.355 3.765-16.773 2.536-4.419 5.891-7.937 10.228-10.637 4.337-2.618 9.164-4.664 14.647-6.055 5.4-1.391 11.046-2.373 16.856-3.027 7.037-.737 12.683-1.391 17.102-1.964 4.337-.573 7.528-1.555 9.574-2.782 1.963-1.309 3.027-3.273 3.027-5.973v-.491c0-5.891-1.718-10.391-5.237-13.664-3.518-3.191-8.51-4.828-15.056-4.828-6.955 0-12.356 1.473-16.447 4.5-4.009 3.028-6.71 6.546-8.183 10.719l-26.348-3.764c2.046-7.282 5.483-13.336 10.31-18.328 4.746-4.909 10.638-8.59 17.511-11.045 6.955-2.455 14.565-3.682 22.912-3.682 5.809 0 11.537.654 17.265 2.045s10.965 3.6 15.711 6.71c4.746 3.109 8.51 7.282 11.455 12.6 2.864 5.318 4.337 11.946 4.337 19.883v80.184h-27.166v-16.446h-.9c-1.719 3.355-4.092 6.464-7.201 9.328-3.109 2.864-6.955 5.237-11.619 6.955-4.828 1.718-10.229 2.536-16.529 2.536Zm7.364-20.701c5.646 0 10.556-1.145 14.729-3.354 4.173-2.291 7.364-5.237 9.655-9.001 2.292-3.763 3.355-7.854 3.355-12.273v-14.155c-.9.737-2.373 1.391-4.5 2.046-2.128.654-4.419 1.145-7.037 1.636-2.619.491-5.155.9-7.692 1.227-2.537.328-4.746.655-6.628.901-4.173.572-8.019 1.472-11.292 2.781-3.355 1.31-5.973 3.11-7.855 5.401-1.964 2.291-2.864 5.318-2.864 8.918 0 5.237 1.882 9.164 5.728 11.782 3.682 2.782 8.51 4.091 14.401 4.091Zm64.643 18.328V71.839h27.412v19.965h1.227c2.21-6.955 5.974-12.274 11.292-16.038 5.319-3.763 11.456-5.645 18.329-5.645 1.555 0 3.355.082 5.237.163 1.964.164 3.601.328 4.91.573v25.938c-1.227-.41-3.109-.819-5.646-1.146a58.814 58.814 0 0 0-7.446-.49c-5.155 0-9.738 1.145-13.829 3.354-4.091 2.209-7.282 5.236-9.655 9.164-2.373 3.927-3.519 8.427-3.519 13.5v70.448h-28.312ZM222.077 39.192l-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
/>
</g>
<defs>
<radialGradient
id="c"
cx="0"
cy="0"
r="1"
gradientTransform="rotate(118.122 171.182 60.81) scale(205.794)"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#FF41F8" />
<stop offset=".707" stop-color="#FF41F8" stop-opacity=".5" />
<stop offset="1" stop-color="#FF41F8" stop-opacity="0" />
</radialGradient>
<linearGradient
id="b"
x1="0"
x2="982"
y1="192"
y2="192"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="#F0060B" />
<stop offset="0" stop-color="#F0070C" />
<stop offset=".526" stop-color="#CC26D5" />
<stop offset="1" stop-color="#7702FF" />
</linearGradient>
<clipPath id="a"><path fill="#fff" d="M0 0h982v239H0z" /></clipPath>
</defs>
</svg>
<h1>Hello, {{ title() }}</h1>
<p>Congratulations! Your app is running. 🎉</p>
</div>
<div class="divider" role="separator" aria-label="Divider"></div>
<div class="right-side">
<div class="pill-group">
@for (item of [
{ title: 'Explore the Docs', link: 'https://angular.dev' },
{ title: 'Learn with Tutorials', link: 'https://angular.dev/tutorials' },
{ title: 'Prompt and best practices for AI', link: 'https://angular.dev/ai/develop-with-ai'},
{ title: 'CLI Docs', link: 'https://angular.dev/tools/cli' },
{ title: 'Angular Language Service', link: 'https://angular.dev/tools/language-service' },
{ title: 'Angular DevTools', link: 'https://angular.dev/tools/devtools' },
]; track item.title) {
<a
class="pill"
[href]="item.link"
target="_blank"
rel="noopener"
>
<span>{{ item.title }}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
height="14"
viewBox="0 -960 960 960"
width="14"
fill="currentColor"
>
<path
d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h280v80H200v560h560v-280h80v280q0 33-23.5 56.5T760-120H200Zm188-212-56-56 372-372H560v-80h280v280h-80v-144L388-332Z"
/>
</svg>
</a>
}
</div>
<div class="social-links">
<a
href="https://github.com/angular/angular"
aria-label="Github"
target="_blank"
rel="noopener"
>
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Github"
>
<path
d="M12.3047 0C5.50634 0 0 5.50942 0 12.3047C0 17.7423 3.52529 22.3535 8.41332 23.9787C9.02856 24.0946 9.25414 23.7142 9.25414 23.3871C9.25414 23.0949 9.24389 22.3207 9.23876 21.2953C5.81601 22.0377 5.09414 19.6444 5.09414 19.6444C4.53427 18.2243 3.72524 17.8449 3.72524 17.8449C2.61064 17.082 3.81137 17.0973 3.81137 17.0973C5.04697 17.1835 5.69604 18.3647 5.69604 18.3647C6.79321 20.2463 8.57636 19.7029 9.27978 19.3881C9.39052 18.5924 9.70736 18.0499 10.0591 17.7423C7.32641 17.4347 4.45429 16.3765 4.45429 11.6618C4.45429 10.3185 4.9311 9.22133 5.72065 8.36C5.58222 8.04931 5.16694 6.79833 5.82831 5.10337C5.82831 5.10337 6.85883 4.77319 9.2121 6.36459C10.1965 6.09082 11.2424 5.95546 12.2883 5.94931C13.3342 5.95546 14.3801 6.09082 15.3644 6.36459C17.7023 4.77319 18.7328 5.10337 18.7328 5.10337C19.3942 6.79833 18.9789 8.04931 18.8559 8.36C19.6403 9.22133 20.1171 10.3185 20.1171 11.6618C20.1171 16.3888 17.2409 17.4296 14.5031 17.7321C14.9338 18.1012 15.3337 18.8559 15.3337 20.0084C15.3337 21.6552 15.3183 22.978 15.3183 23.3779C15.3183 23.7009 15.5336 24.0854 16.1642 23.9623C21.0871 22.3484 24.6094 17.7341 24.6094 12.3047C24.6094 5.50942 19.0999 0 12.3047 0Z"
/>
</svg>
</a>
<a
href="https://x.com/angular"
aria-label="X"
target="_blank"
rel="noopener"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="X"
>
<path
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
/>
</svg>
</a>
<a
href="https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw"
aria-label="Youtube"
target="_blank"
rel="noopener"
>
<svg
width="29"
height="20"
viewBox="0 0 29 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Youtube"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M27.4896 1.52422C27.9301 1.96749 28.2463 2.51866 28.4068 3.12258C29.0004 5.35161 29.0004 10 29.0004 10C29.0004 10 29.0004 14.6484 28.4068 16.8774C28.2463 17.4813 27.9301 18.0325 27.4896 18.4758C27.0492 18.9191 26.5 19.2389 25.8972 19.4032C23.6778 20 14.8068 20 14.8068 20C14.8068 20 5.93586 20 3.71651 19.4032C3.11363 19.2389 2.56449 18.9191 2.12405 18.4758C1.68361 18.0325 1.36732 17.4813 1.20683 16.8774C0.613281 14.6484 0.613281 10 0.613281 10C0.613281 10 0.613281 5.35161 1.20683 3.12258C1.36732 2.51866 1.68361 1.96749 2.12405 1.52422C2.56449 1.08095 3.11363 0.76113 3.71651 0.596774C5.93586 0 14.8068 0 14.8068 0C14.8068 0 23.6778 0 25.8972 0.596774C26.5 0.76113 27.0492 1.08095 27.4896 1.52422ZM19.3229 10L11.9036 5.77905V14.221L19.3229 10Z"
/>
</svg>
</a>
</div>
</div>
</div>
</main>
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * The content above * * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * End of Placeholder * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<router-outlet />
<div class="content">
<router-outlet></router-outlet>
</div>
<app-bottom-navigation-bar [options]="menuOptions"/>

View File

@@ -1,3 +1,30 @@
import { Routes } from '@angular/router';
export const routes: Routes = [];
import { Home } from './pages/home/home';
import { routes as settingsRoutes } from './pages/settings/settings.route'
export const routes: Routes = [
{
path: 'home',
component: Home,
},
{
path: 'register',
component: Home,
},
{
path: 'budget',
component: Home,
},
{
path: 'settings',
children: settingsRoutes
},
{
path: '',
pathMatch: 'full',
redirectTo: 'home',
},
{
path: '**',
redirectTo: 'home',
},
];

View File

@@ -0,0 +1,19 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
max-height: 100vh;
width: 100%;
}
.content {
height: calc(100% - 56px);
max-height: calc(100vh - 56px);
overflow: auto;
}
app-bottom-navigation-bar {
margin-top: auto;
width: 100%;
z-index: 2;
}

View File

@@ -1,10 +1,13 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
import { provideTranslateService } from '@ngx-translate/core';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [provideTranslateService({})],
}).compileComponents();
});
@@ -13,5 +16,4 @@ describe('App', () => {
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
});

View File

@@ -1,12 +1,33 @@
import { Component, signal } from '@angular/core';
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { BottomNavigationBar } from './components/bottom-navigation-bar/bottom-navigation-bar';
import { BottomNavigationBarOption } from './components/bottom-navigation-bar/BottomNavigationBarOption';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
imports: [RouterOutlet, BottomNavigationBar],
templateUrl: './app.html',
styleUrl: './app.scss'
styleUrls: [
'./app.scss'
],
})
export class App {
protected readonly title = signal('groceries-price-tracker');
protected menuOptions = [
new BottomNavigationBarOption('navbar.label.home', 'home', '/home'),
new BottomNavigationBarOption(
'navbar.label.register',
'barcode',
'/register',
'material-symbols-outlined',
),
new BottomNavigationBarOption(
'navbar.label.budget',
'barcode_reader',
'/budget',
'material-symbols-outlined',
),
new BottomNavigationBarOption('navbar.label.settings', 'settings', '/settings'),
];
}

View File

@@ -0,0 +1,3 @@
<div class="action-btn">
<button matButton="outlined" [disabled]="disabled()">{{text()}}</button>
</div>

View File

@@ -0,0 +1,12 @@
:host {
pointer-events: none;
}
.action-btn {
padding: 24px 0;
width: 100%;
button {
width: 100%;
pointer-events: all;
}
}

View File

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

View File

@@ -0,0 +1,13 @@
import { Component, input } from '@angular/core';
import { MatButton } from '@angular/material/button';
@Component({
selector: 'app-action-btn',
imports: [MatButton],
templateUrl: './action-btn.html',
styleUrl: './action-btn.scss',
})
export class ActionBtn {
disabled = input<boolean>(true);
text = input('');
}

View File

@@ -0,0 +1,8 @@
export class BottomNavigationBarOption {
constructor(
public readonly label: string,
public readonly icon: string,
public readonly path: string,
public readonly fontSet: string = '',
) {}
}

View File

@@ -0,0 +1,8 @@
<mat-toolbar class="bottom-nav">
@for(option of options(); track option.path) {
<button [routerLink]="option.path" mat-button class="bottom-nav__item" routerLinkActive="bottom-nav__item--selected">
<mat-icon [fontSet]="option.fontSet">{{option.icon}}</mat-icon>
<span>{{option.label|translate|titlecase}}</span>
</button>
}
</mat-toolbar>

View File

@@ -0,0 +1,37 @@
.bottom-nav {
align-items: center;
background: var(--mat-sys-primary-container);
display: flex;
height: 56px;
justify-content: space-around;
&__item {
align-items: center;
background-color: unset;
border: none;
display: flex;
flex-direction: column;
& mat-icon {
color: rgba(#ffffff, 0.6);
height: 24px;
width: 24px;
}
& span {
color: rgba(#ffffff, 0.6);
font-size: 0.75rem;
line-height: 1;
margin-top: 4px;
}
&--selected {
& mat-icon {
color: #ffffff;
}
& span {
color: #ffffff;
}
}
}
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BottomNavigationBar } from './bottom-navigation-bar';
describe('BottomNavigationBar', () => {
let component: BottomNavigationBar;
let fixture: ComponentFixture<BottomNavigationBar>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [BottomNavigationBar]
})
.compileComponents();
fixture = TestBed.createComponent(BottomNavigationBar);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,17 @@
import { TitleCasePipe } from '@angular/common';
import { Component, input } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatToolbarModule } from '@angular/material/toolbar';
import { TranslatePipe } from '@ngx-translate/core';
import { BottomNavigationBarOption } from './BottomNavigationBarOption';
import { RouterLink, RouterLinkActive } from "@angular/router";
@Component({
selector: 'app-bottom-navigation-bar',
imports: [MatToolbarModule, MatIconModule, TranslatePipe, TitleCasePipe, RouterLink, RouterLinkActive],
templateUrl: './bottom-navigation-bar.html',
styleUrl: './bottom-navigation-bar.scss',
})
export class BottomNavigationBar {
readonly options = input<BottomNavigationBarOption[]>([]);
}

View File

@@ -0,0 +1,51 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ChainAdd } from './chain-add';
import { vi } from 'vitest';
import { provideTranslateService } from '@ngx-translate/core';
import { ChainFormGroup } from '../../pages/settings/chains/chain-formgroup';
import { FormControl } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { ChainSettings } from '../../services/chain-settings';
import { Chain } from '../../models/Chain';
describe('ChainAdd', () => {
let component: ChainAdd;
let fixture: ComponentFixture<ChainAdd>;
let chainSettings: Partial<ChainSettings>;
beforeEach(async () => {
chainSettings = {
save: vi.fn().mockResolvedValue(undefined),
};
await TestBed.configureTestingModule({
imports: [ChainAdd],
providers: [provideTranslateService(), { provide: ChainSettings, useValue: chainSettings }],
}).compileComponents();
fixture = TestBed.createComponent(ChainAdd);
component = fixture.componentInstance;
});
it('should create', async () => {
await fixture.whenStable();
expect(component).toBeTruthy();
});
it('should insert chain and store image', async () => {
(<any>component).form = new ChainFormGroup({
name: new FormControl('Mock'),
image: new FormControl(new File([], 'mock')),
});
await fixture.whenStable();
const actionBtn = fixture.debugElement.query(By.css('app-action-btn'));
actionBtn.triggerEventHandler('click');
await fixture.whenStable();
expect(chainSettings.save).toHaveBeenCalledExactlyOnceWith(
new Chain(component.form.controls.name.value, ''),
component.form.controls.image.value,
);
});
});

View File

@@ -0,0 +1,34 @@
import { Component, inject } from '@angular/core';
import { Chain } from '../../models/Chain';
import { ChainFormGroup } from '../../pages/settings/chains/chain-formgroup';
import { ActionBtn } from '../action-btn/action-btn';
import { TranslatePipe } from '@ngx-translate/core';
import { UpperfirstPipe } from '../../pipes/upperfirst-pipe';
import { ChainSettings } from '../../services/chain-settings';
import { SettingsBaseAddEdit } from '../settings-base-add-edit/settings-base-add-edit';
@Component({
selector: 'app-chain-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 ChainAdd extends SettingsBaseAddEdit {
private readonly chainSettings = inject(ChainSettings);
readonly form = new ChainFormGroup();
btnText = 'common.save';
title = 'settings.chain.new_chain';
async submit() {
const name = this.form.controls.name.value;
const img = this.form.controls.image.value;
//TODO: the sqlite bridge can't handle null as param
const chain = new Chain(name, '');
await this.chainSettings.save(chain, img);
}
}

View File

@@ -0,0 +1,79 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ChainEdit } from './chain-edit';
import { ActivatedRoute } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import { Chain } from '../../models/Chain';
import { provideTranslateService } from '@ngx-translate/core';
import { By } from '@angular/platform-browser';
import { ImageStorage } from '../../services/image-storage';
import { ChainSettings } from '../../services/chain-settings';
const CHAIN_MOCK = new Chain('Mock', '', 1);
describe('ChainEdit', () => {
let component: ChainEdit;
let fixture: ComponentFixture<ChainEdit>;
let activatedRoute: Partial<ActivatedRoute>;
let chainSettings: Partial<ChainSettings>;
let imageStorage: Partial<ImageStorage>;
const dataSubject = new BehaviorSubject({ chain: CHAIN_MOCK });
beforeEach(async () => {
activatedRoute = {
data: dataSubject,
};
chainSettings = {
update: vi.fn(),
};
imageStorage = {
getImage: vi.fn(),
saveImage: vi.fn(),
deleteImage: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [ChainEdit],
providers: [
provideTranslateService(),
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: ChainSettings, useValue: chainSettings },
{ provide: ImageStorage, useValue: imageStorage },
],
}).compileComponents();
fixture = TestBed.createComponent(ChainEdit);
component = fixture.componentInstance;
});
it('should create', async () => {
await fixture.whenStable();
expect(component).toBeTruthy();
});
it('should call chainSettings update on chain update', async () => {
const CHAIN_NAME_UPDATE_MOCK = 'name update mock';
await fixture.whenStable();
//User updates name input field
component.form.controls.name.patchValue(CHAIN_NAME_UPDATE_MOCK)
fixture.whenStable();
const actionBtn = fixture.debugElement.query(By.css('app-action-btn'));
actionBtn.triggerEventHandler('click');
await fixture.whenStable();
expect(chainSettings.update).toHaveBeenCalledExactlyOnceWith(
CHAIN_MOCK,
new Chain(CHAIN_NAME_UPDATE_MOCK, component['chain']!.image, component['chain']!.id),
component.form.controls.image.value,
);
});
it('should patch form with chain data', async () => {
const IMAGE_FILE_MOCK = new Blob([], { type: 'image/png' });
imageStorage.getImage = vi.fn().mockResolvedValue(IMAGE_FILE_MOCK);
const CHAIN_MOCK = new Chain('name', 'image.png', 1);
dataSubject.next({ chain: CHAIN_MOCK });
await fixture.whenStable();
expect(component.form.controls.name.value).toEqual(CHAIN_MOCK.name);
expect(component.form.controls.image.value?.type).toEqual(IMAGE_FILE_MOCK.type);
});
});

View File

@@ -0,0 +1,63 @@
import { Component, inject, OnInit } from '@angular/core';
import { ChainFormGroup } from '../../pages/settings/chains/chain-formgroup';
import { ActivatedRoute } from '@angular/router';
import { Observable, take, tap } from 'rxjs';
import { Chain } from '../../models/Chain';
import { ImageStorage } from '../../services/image-storage';
import { ActionBtn } from '../action-btn/action-btn';
import { TranslatePipe } from '@ngx-translate/core';
import { UpperfirstPipe } from '../../pipes/upperfirst-pipe';
import { ChainSettings } from '../../services/chain-settings';
import { SettingsBaseAddEdit } from '../settings-base-add-edit/settings-base-add-edit';
@Component({
selector: 'app-chain-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 ChainEdit extends SettingsBaseAddEdit implements OnInit {
protected readonly activatedRoute = inject(ActivatedRoute);
private readonly imageStorage = inject(ImageStorage);
private readonly chainSettings = inject(ChainSettings);
private chain?: Chain;
btnText = 'common.update';
title = 'settings.chain.edit_chain';
readonly form = new ChainFormGroup();
ngOnInit() {
(<Observable<{ chain: Chain }>>this.activatedRoute.data)
.pipe(
take(1),
tap((data) => (this.chain = new Chain(data.chain.name, data.chain.image, data.chain.id))),
tap((data) => this.patchForm(data.chain)),
)
.subscribe();
}
async patchForm(chain: Chain) {
try {
this.form.controls.name.patchValue(chain.name);
if (chain.image) {
const imgName = chain.image;
const blob = await this.imageStorage.getImage(imgName);
const file = new File([blob], imgName, { type: blob.type });
this.form.controls.image.patchValue(file);
}
} catch (e) {
console.error(e);
//TODO: reportar error
}
}
async submit() {
if (this.chain) {
const updatedName = this.form.controls.name.value;
const updatedImg = this.form.controls.image.value;
const updatedChain = new Chain(updatedName, '', this.chain.id);
await this.chainSettings.update(this.chain, updatedChain, updatedImg);
}
}
}

View File

@@ -0,0 +1,11 @@
<mat-form-field>
<mat-label>{{'settings.chain.chain'|translate|upperfirst}}</mat-label>
<mat-select
[formControl]="control"
[compareWith]="compareFn"
>
@for(chain of chains$|async; track chain.id) {
<mat-option [value]="chain">{{chain.name}}</mat-option>
}
</mat-select>
</mat-form-field>

View File

@@ -0,0 +1,3 @@
mat-form-field {
width: 100%;
}

View File

@@ -0,0 +1,65 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { MatSelect } from '@angular/material/select';
import { provideTranslateService } from '@ngx-translate/core';
import { Chain } from '../../models/Chain';
import { ChainDAO } from '../../dao/ChainDAO';
import { ChainSelect } from './chain-select';
@Component({
selector: 'app-chain-select-mock',
imports: [
ChainSelect,
ReactiveFormsModule
],
template: `
<form [formGroup]="form">
<app-chain-select formControlName="mock"/>
</form>
`,
styleUrl: './chain-select.scss',
})
class ChainSelectTestbed {
form = new FormGroup({mock: new FormControl()})
}
describe('ChainSelect', () => {
let component: ChainSelectTestbed;
let fixture: ComponentFixture<ChainSelectTestbed>;
let chainDAO: Partial<ChainDAO>;
let CHAINS_MOCK: Chain[];
beforeEach(async () => {
CHAINS_MOCK = [new Chain('Mock 1', '', 1)];
chainDAO = {
findAll: vi.fn().mockResolvedValue(CHAINS_MOCK),
};
await TestBed.configureTestingModule({
imports: [ChainSelectTestbed],
providers: [{ provide: ChainDAO, useValue: chainDAO }, provideTranslateService()],
}).compileComponents();
fixture = TestBed.createComponent(ChainSelectTestbed);
component = fixture.componentInstance;
});
it('should create', async () => {
await fixture.whenStable();
expect(component).toBeTruthy();
});
it('should match form chain with chain option', async () => {
component.form = new FormGroup({mock: new FormControl(CHAINS_MOCK[0])});
await fixture.whenStable();
const matSelect: MatSelect = fixture.debugElement.query(By.css('mat-select')).componentInstance;
expect(matSelect.value).toEqual(CHAINS_MOCK[0]);
});
});

View File

@@ -0,0 +1,53 @@
import { Component, inject, OnInit, Optional, Self } from '@angular/core';
import { ChainDAO } from '../../dao/ChainDAO';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { AsyncPipe } from '@angular/common';
import { ControlValueAccessor, FormControl, NgControl, ReactiveFormsModule } from '@angular/forms';
import { Chain } from '../../models/Chain';
import { TranslatePipe } from '@ngx-translate/core';
import { UpperfirstPipe } from "../../pipes/upperfirst-pipe";
@Component({
selector: 'app-chain-select',
imports: [
AsyncPipe,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
ReactiveFormsModule,
TranslatePipe,
UpperfirstPipe
],
templateUrl: './chain-select.html',
styleUrl: './chain-select.scss',
})
export class ChainSelect implements ControlValueAccessor, OnInit {
private readonly chainDAO = inject(ChainDAO);
protected chains$ = this.chainDAO.findAll();
protected control = new FormControl();
constructor(@Self() @Optional() public controlDir: NgControl) {
if (this.controlDir) {
this.controlDir.valueAccessor = this;
}
}
ngOnInit() {
this.control = <FormControl>this.controlDir.control;
}
writeValue(obj: any): void {}
registerOnChange(onChange: any) {}
registerOnTouched(fn: any): void {}
setDisabledState?(isDisabled: boolean): void {}
compareFn(c1: Chain, c2: Chain) {
if (!c1 || !c2) return false;
return c1.id === c2.id;
}
}

View File

@@ -0,0 +1,52 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EstablishmentAdd } from './establishment-add';
import { provideTranslateService } from '@ngx-translate/core';
import { Chain } from '../../models/Chain';
import { Establishment } from '../../models/Establishment';
import { EstablishmentSettings } from '../../services/establishment-settings';
import { EstablishmentForm } from '../../pages/settings/establishments/establishment-form/establishment-form';
describe('EstablishmentAdd', () => {
let component: EstablishmentAdd;
let fixture: ComponentFixture<EstablishmentAdd>;
let establishmentSettings: Partial<EstablishmentSettings>;
beforeEach(async () => {
establishmentSettings = {
save: vi.fn(),
};
TestBed.overrideComponent(EstablishmentForm, {
set: { template: `` },
});
await TestBed.configureTestingModule({
imports: [EstablishmentAdd],
providers: [
provideTranslateService(),
{ provide: EstablishmentSettings, useValue: establishmentSettings },
],
}).compileComponents();
fixture = TestBed.createComponent(EstablishmentAdd);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should save on valid form', async () => {
const SELECTED_CHAIN_MOCK = new Chain('Mock', 'mock_logo.png', 1);
const ADDRESS_MOCK = 'Mock street';
await component.submit();
//form is invalid so it doesnt call establishmenDAO save method
expect(establishmentSettings.save).not.toHaveBeenCalled();
//User chooses chain, form is valid
component['form'].patchValue({ chain: SELECTED_CHAIN_MOCK, address: ADDRESS_MOCK });
await component.submit();
const establishment = new Establishment(SELECTED_CHAIN_MOCK, ADDRESS_MOCK);
expect(establishmentSettings.save).toHaveBeenCalledExactlyOnceWith(establishment);
});
});

View File

@@ -0,0 +1,36 @@
import { Component, inject } from '@angular/core';
import { EstablishmentSettings } from '../../services/establishment-settings';
import { Establishment } from '../../models/Establishment';
import { ActionBtn } from '../action-btn/action-btn';
import { TranslatePipe } from '@ngx-translate/core';
import { UpperfirstPipe } from '../../pipes/upperfirst-pipe';
import { EstablishmentFormGroup } from '../../pages/settings/establishments/establishment-formgroup';
import { SettingsBaseAddEdit } from '../settings-base-add-edit/settings-base-add-edit';
@Component({
selector: 'app-establishment-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 EstablishmentAdd extends SettingsBaseAddEdit {
private readonly establishmentSettings = inject(EstablishmentSettings);
readonly form = new EstablishmentFormGroup();
btnText = 'common.save';
title = 'settings.establishment.new_establishment';
async submit() {
const chain = this.form.controls.chain.value;
const address = this.form.controls.address.value;
if (chain?.id) {
const establishment = new Establishment(chain, address);
this.establishmentSettings.save(establishment);
}
}
}

View File

@@ -0,0 +1,62 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EstablishmentEdit } from './establishment-edit';
import { ActivatedRoute } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import { Establishment } from '../../models/Establishment';
import { Chain } from '../../models/Chain';
import { EstablishmentSettings } from '../../services/establishment-settings';
import { provideTranslateService } from '@ngx-translate/core';
import { EstablishmentForm } from '../../pages/settings/establishments/establishment-form/establishment-form';
import { By } from '@angular/platform-browser';
describe('EstablishmentEdit', () => {
let component: EstablishmentEdit;
let fixture: ComponentFixture<EstablishmentEdit>;
let activatedRoute: Partial<ActivatedRoute>;
let establishmentSettings: Partial<EstablishmentSettings>;
const data = new BehaviorSubject({
establishment: new Establishment(new Chain('mock', 'logo_mock.jpg', 1), 'mock street', 1),
});
beforeEach(async () => {
activatedRoute = {
data,
};
establishmentSettings = {
update: vi.fn(),
};
TestBed.overrideComponent(EstablishmentForm, {
set: { template: `` },
});
await TestBed.configureTestingModule({
imports: [EstablishmentEdit],
providers: [
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: EstablishmentSettings, useValue: establishmentSettings },
provideTranslateService(),
],
}).compileComponents();
fixture = TestBed.createComponent(EstablishmentEdit);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should call establishmentSettings update', async () => {
const CHAIN_MOCK = new Chain('mock', 'logo_mock.png', 2);
const ADDRESS_MOCK = 'mock street';
//User selects chain
component['form'].patchValue({chain: CHAIN_MOCK, address: ADDRESS_MOCK});
const submitBtn = fixture.debugElement.query(By.css('app-action-btn'));
submitBtn.triggerEventHandler('click');
await fixture.whenStable()
expect(establishmentSettings.update).toHaveBeenCalledExactlyOnceWith(new Establishment(CHAIN_MOCK, ADDRESS_MOCK, component['establishment']!.id));
});
});

View File

@@ -0,0 +1,53 @@
import { Component, inject, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { UpperfirstPipe } from '../../pipes/upperfirst-pipe';
import { TranslatePipe } from '@ngx-translate/core';
import { Establishment } from '../../models/Establishment';
import { Observable, take, tap } from 'rxjs';
import { EstablishmentFormGroup } from '../../pages/settings/establishments/establishment-formgroup';
import { ActionBtn } from '../action-btn/action-btn';
import { EstablishmentSettings } from '../../services/establishment-settings';
import { SettingsBaseAddEdit } from '../settings-base-add-edit/settings-base-add-edit';
@Component({
selector: 'app-establishment-edit',
imports: [UpperfirstPipe, TranslatePipe, ActionBtn],
templateUrl: './../settings-base-add-edit/settings-base-add-edit.html',
styleUrl: './../settings-base-add-edit/settings-base-add-edit.scss',
})
export class EstablishmentEdit extends SettingsBaseAddEdit implements OnInit {
private readonly activatedRoute = inject(ActivatedRoute);
private readonly establishmentSettings = inject(EstablishmentSettings);
private establishment?: Establishment;
readonly form = new EstablishmentFormGroup();
btnText = 'common.update';
title = 'settings.establishment.edit_establishment'
ngOnInit() {
(<Observable<{ establishment: Establishment }>>this.activatedRoute.data)
.pipe(
take(1),
tap((data) => (this.establishment = data.establishment)),
tap((data) => this.patchForm(data.establishment)),
)
.subscribe();
}
patchForm(establishment: Establishment) {
this.form.patchValue({
chain: establishment.chain,
address: establishment.address,
});
}
async submit() {
const chain = this.form.controls.chain.value;
if (chain && this.establishment?.id) {
const address = this.form.controls.address.value;
const establishment = new Establishment(chain, address, this.establishment.id);
await this.establishmentSettings.update(establishment);
}
}
}

View File

@@ -0,0 +1,5 @@
<mat-toolbar>
<button matIconButton (click)="bigClick.emit()">
<mat-icon>{{icon()}}</mat-icon>
</button>
</mat-toolbar>

View File

@@ -0,0 +1,23 @@
:host {
bottom: 80px;
position: absolute;
right: 0px;
}
mat-toolbar {
button {
background: var(--mat-sys-primary-container);
width: 64px;
height: 64px;
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
color: white;
}
}
mat-icon {
color: white;
}
}

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FloatingBigBtn } from './floating-big-btn';
describe('FloatingBigBtn', () => {
let component: FloatingBigBtn;
let fixture: ComponentFixture<FloatingBigBtn>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FloatingBigBtn]
})
.compileComponents();
fixture = TestBed.createComponent(FloatingBigBtn);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,15 @@
import { Component, input, output } from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatToolbarModule } from '@angular/material/toolbar';
@Component({
selector: 'app-floating-big-btn',
imports: [MatToolbarModule, MatIcon, MatIconButton],
templateUrl: './floating-big-btn.html',
styleUrl: './floating-big-btn.scss',
})
export class FloatingBigBtn {
readonly bigClick = output();
readonly icon = input('add');
}

View File

@@ -0,0 +1,3 @@
export class IconActionListItem {
constructor(public iconSrc: string, public label = '', public value: string){}
}

View File

@@ -0,0 +1,8 @@
<mat-action-list>
@for (item of items(); track item.value) {
<button mat-list-item (click)="action.emit(item.value)">
<img matListItemAvatar src="{{item.iconSrc}}" alt=""/>
<span matListItemTitle>{{item.label|translate|upperfirst}}</span>
</button>
}
</mat-action-list>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { IconActionList } from './icon-action-list';
describe('IconActionList', () => {
let component: IconActionList;
let fixture: ComponentFixture<IconActionList>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [IconActionList]
})
.compileComponents();
fixture = TestBed.createComponent(IconActionList);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,16 @@
import { Component, input, output } from '@angular/core';
import { MatListModule } from '@angular/material/list';
import { IconActionListItem } from './IconActionListItem';
import { TranslatePipe } from '@ngx-translate/core';
import { UpperfirstPipe } from "../../pipes/upperfirst-pipe";
@Component({
selector: 'app-icon-action-list',
imports: [MatListModule, TranslatePipe, UpperfirstPipe],
templateUrl: './icon-action-list.html',
styleUrl: './icon-action-list.scss',
})
export class IconActionList {
readonly items = input<IconActionListItem[]>([]);
readonly action = output<string>();
}

View File

@@ -0,0 +1,3 @@
export class IconNavListItem {
constructor(public icon = 'thumb_up' , public label = '', public routerLink: string|string[] = ['/']){}
}

View File

@@ -0,0 +1,8 @@
<mat-nav-list>
@for (item of items(); track item.label) {
<a mat-list-item [routerLink]="item.routerLink">
<mat-icon matListItemIcon>{{item.icon}}</mat-icon>
<span matListItemTitle>{{item.label|translate|upperfirst }}</span>
</a>
}
</mat-nav-list>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { IconNavList } from './icon-nav-list';
describe('IconNavList', () => {
let component: IconNavList;
let fixture: ComponentFixture<IconNavList>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [IconNavList]
})
.compileComponents();
fixture = TestBed.createComponent(IconNavList);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,17 @@
import { Component, input } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
import { RouterLink } from '@angular/router';
import { IconNavListItem } from './IconNavListItem';
import { TranslatePipe } from '@ngx-translate/core';
import { UpperfirstPipe } from "../../pipes/upperfirst-pipe";
@Component({
selector: 'app-icon-nav-list',
imports: [MatListModule, MatIcon, RouterLink, TranslatePipe, UpperfirstPipe],
templateUrl: './icon-nav-list.html',
styleUrl: './icon-nav-list.scss',
})
export class IconNavList {
readonly items = input<IconNavListItem[]>([]);
}

View File

@@ -0,0 +1,20 @@
@if (imgUrl()) {
<div class="image-preview__container">
<img [src]="imgUrl()" [alt]="fileName()" width="300" height="300" />
</div>
}
<input
(change)="onFileSelected($event)"
#fileUpload
accept=".jpeg, .jpg, .png, .webp, .gif"
class="file-input"
type="file"
/>
<div class="file-upload">
<div class="file-upload__description" title="{{ fileName() }}">
{{ fileName() || 'common.no_file_yet' | translate | upperfirst }}
</div>
<button matMiniFab color="primary" class="upload-btn" (click)="fileUpload.click()">
<mat-icon>attach_file</mat-icon>
</button>
</div>

View File

@@ -0,0 +1,26 @@
.image-preview {
&__container {
display: flex;
justify-content: center;
margin-bottom: 16px;
}
}
.file-input {
display: none;
}
.file-upload {
align-items: center;
box-sizing: border-box;
display: inline-flex;
justify-content: space-between;
min-width: 196px;
width: 100%;
&__description {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}

View File

@@ -0,0 +1,69 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ImageUploader } from './image-uploader';
import { provideTranslateService } from '@ngx-translate/core';
import { By } from '@angular/platform-browser';
import { FiletypeUtils } from '../../services/filetype-utils';
describe('ImageUploader', () => {
let component: ImageUploader;
let fixture: ComponentFixture<ImageUploader>;
let filetypeUtils: Partial<FiletypeUtils>;
beforeEach(async () => {
filetypeUtils = {
isValidImageMimeType: vi.fn().mockReturnValue(true)
};
await TestBed.configureTestingModule({
imports: [ImageUploader],
providers: [provideTranslateService(), { provide: FiletypeUtils, useValue: filetypeUtils }],
}).compileComponents();
fixture = TestBed.createComponent(ImageUploader);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should update selection when fileIn input changes', async () => {
const file = new File([''], 'test.png', { type: 'image/png' });
fixture.componentRef.setInput('fileIn', file);
fixture.detectChanges();
const descriptionEl = fixture.debugElement.query(By.css('.file-upload__description'));
expect((<HTMLDivElement>descriptionEl.nativeElement).textContent.toLowerCase().trim()).toEqual(
file.name,
);
});
describe('onFileSelected', () => {
it('should update and emit selection on valid file', () => {
const emitSpy = vi.spyOn(component.file, 'emit');
const file = new File([''], 'test.png', { type: 'image/png' });
const inputEvent = { target: { files: [file] } } as any;
component.onFileSelected(inputEvent);
fixture.detectChanges();
const descriptionEl = fixture.debugElement.query(By.css('.file-upload__description'));
expect(
(<HTMLDivElement>descriptionEl.nativeElement).textContent.toLowerCase().trim(),
).toEqual(file.name);
expect(emitSpy).toHaveBeenCalledOnce();
});
it('should discard file if not valid type', () => {
filetypeUtils.isValidImageMimeType = vi.fn().mockReturnValue(false);
const file = new File([''], 'test.txt', { type: 'text/txt' });
const inputEvent = { target: { files: [file] } } as any;
component.onFileSelected(inputEvent);
fixture.detectChanges();
const descriptionEl = fixture.debugElement.query(By.css('.file-upload__description'));
expect(
(<HTMLDivElement>descriptionEl.nativeElement).textContent.toLowerCase().trim(),
).not.toEqual(file.name);
});
});
});

View File

@@ -0,0 +1,45 @@
import { Component, effect, inject, input, output, signal } from '@angular/core';
import { MatMiniFabButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { UpperfirstPipe } from '../../pipes/upperfirst-pipe';
import { FiletypeUtils } from '../../services/filetype-utils';
@Component({
selector: 'app-image-uploader',
imports: [MatIcon, MatMiniFabButton, TranslatePipe, UpperfirstPipe],
templateUrl: './image-uploader.html',
styleUrl: './image-uploader.scss',
})
export class ImageUploader {
file = output<File>();
fileIn = input<File | null>(null);
protected readonly fileName = signal('');
protected readonly imgUrl = signal('');
private readonly filetypeUtils = inject(FiletypeUtils);
constructor() {
effect(() => {
if (this.fileIn()) {
const file = <File>this.fileIn();
this.updateSelection(file);
}
});
}
onFileSelected(e: Partial<Event>) {
const files = (<HTMLInputElement>e.target).files;
if (files) {
const file = files[0];
if (file && this.filetypeUtils.isValidImageMimeType(file.type)) {
this.updateSelection(file);
this.file.emit(file);
}
}
}
private updateSelection(file: File) {
this.fileName.set(file.name);
this.imgUrl.set(URL.createObjectURL(file));
}
}

View File

@@ -0,0 +1,9 @@
<h3>{{title|translate|upperfirst}}</h3>
<ng-content></ng-content>
<app-action-btn
(click)="submit()"
(keydown)="submit()"
[disabled]="this.disabled"
class="top-auto"
text="{{btnText|translate|upperfirst}}"
/>

View File

@@ -0,0 +1,9 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
}
h3 {
margin-top: 0;
}

View File

@@ -0,0 +1,13 @@
import { FormGroup } from '@angular/forms';
export abstract class SettingsBaseAddEdit {
abstract btnText: string;
abstract title: string;
abstract form: FormGroup;
protected abstract submit(): Promise<void>
get disabled() {
return this.form.invalid || this.form.pristine;
}
}

View File

@@ -0,0 +1,2 @@
<app-simple-list-w-actions [items]="(data$|async) ?? []" (action)="edit($event)"/>
<app-floating-big-btn icon="add" (bigClick)="add()"/>

View File

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

View File

@@ -0,0 +1,9 @@
import { Observable } from 'rxjs';
import { SimpleListItem } from '../simple-list-w-actions/SimpleListItem';
export abstract class SettingsBaseList {
abstract data$: Observable<SimpleListItem[]>;
protected abstract edit(action: { action: string; subject: string }): void;
protected abstract add(): void;
}

View File

@@ -0,0 +1,11 @@
<div class="content">
<header>
@if(withBackBtn()){
<button class="icon" (click)="location.back()">
<mat-icon>arrow_back</mat-icon>
</button>
}
<h1>{{title()|translate|upperfirst}}</h1>
</header>
<ng-content></ng-content>
</div>

View File

@@ -0,0 +1,19 @@
header {
display: flex;
button {
background-color: unset;
border: unset;
}
& .icon {
align-items: center;
display: flex;
padding-right: 16px;
}
}
.content {
display: flex;
flex-direction: column;
height: 100%;
padding: 0px 16px;
}

View File

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

View File

@@ -0,0 +1,16 @@
import { Component, inject, input } from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import { Location } from '@angular/common';
import { MatIcon } from "@angular/material/icon";
import { UpperfirstPipe } from "../../pipes/upperfirst-pipe";
@Component({
selector: 'app-simple-layout',
imports: [TranslatePipe, MatIcon, UpperfirstPipe],
templateUrl: './simple-layout.html',
styleUrl: './simple-layout.scss',
})
export class SimpleLayout {
protected location = inject(Location)
readonly title = input('');
readonly withBackBtn = input(false);
}

View File

@@ -0,0 +1,5 @@
import { SimpleListItemAction } from "./SimpleListItemAction";
export class SimpleListItem {
constructor(public id: string, public text: string = '', public actions: SimpleListItemAction[] = []) {}
}

View File

@@ -0,0 +1,3 @@
export class SimpleListItemAction {
constructor(public icon: string, public action: string) {}
}

View File

@@ -0,0 +1,17 @@
<mat-list>
@for (item of items(); track item.id) {
<mat-list-item>
{{ item.text }}
@if (item.actions; as actions) {
<div matListItemMeta>
@for (action of actions; track action.action) {
<button matIconButton (click)="this.action.emit({action: action.action, subject: item.id})">
<mat-icon>{{ action.icon }}</mat-icon>
</button>
}
</div>
}
</mat-list-item>
<mat-divider></mat-divider>
}
</mat-list>

View File

@@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SimpleListWActions } from './simple-list-w-actions';
describe('SimpleListWActions', () => {
let component: SimpleListWActions;
let fixture: ComponentFixture<SimpleListWActions>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SimpleListWActions]
})
.compileComponents();
fixture = TestBed.createComponent(SimpleListWActions);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,16 @@
import { Component, input, output } from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatDivider, MatListModule } from '@angular/material/list';
import { SimpleListItem } from './SimpleListItem';
@Component({
selector: 'app-simple-list-w-actions',
imports: [MatListModule, MatIcon, MatDivider, MatIconButton],
templateUrl: './simple-list-w-actions.html',
styleUrl: './simple-list-w-actions.scss',
})
export class SimpleListWActions {
readonly action = output<{action: string, subject: string}>()
readonly items = input<SimpleListItem[]>([])
}

View File

@@ -71,6 +71,6 @@ describe('EstablishmentDAO', () => {
sqlite.executeQuery = vi.fn().mockResolvedValue({ rows: [] });
ESTABLISHMENT_MOCK.id = undefined;
ESTABLISHMENT_MOCK.chain.id = undefined;
expect(service.insert(ESTABLISHMENT_MOCK)).rejects.toThrow();
await expect(service.insert(ESTABLISHMENT_MOCK)).rejects.toThrow();
});
});

View File

@@ -74,13 +74,13 @@ describe('ProductEstablishmentDAO', () => {
it('should throw if toDB is called with a product that has no id', async () => {
sqlite.executeQuery = vi.fn().mockResolvedValue({ rows: [] });
PRODUCT_ESTABLISHMENT_MOCK.product.id = undefined;
expect(service.insert(PRODUCT_ESTABLISHMENT_MOCK)).rejects.toThrow();
await expect(service.insert(PRODUCT_ESTABLISHMENT_MOCK)).rejects.toThrow();
});
it('should throw if toDB is called with a establishmen that has no id', async () => {
sqlite.executeQuery = vi.fn().mockResolvedValue({ rows: [] });
PRODUCT_ESTABLISHMENT_MOCK.establishment.id = undefined;
expect(service.insert(PRODUCT_ESTABLISHMENT_MOCK)).rejects.toThrow();
await expect(service.insert(PRODUCT_ESTABLISHMENT_MOCK)).rejects.toThrow();
});
});
});

View File

@@ -83,13 +83,13 @@ describe('PurchaseDAO', () => {
it('should throw if toDB is called with a establishmen that has no id', async () => {
sqlite.executeQuery = vi.fn().mockResolvedValue({ rows: [] });
PURCHASE_MOCK.establishment.id = undefined;
expect(service.insert(PURCHASE_MOCK)).rejects.toThrow();
await expect(service.insert(PURCHASE_MOCK)).rejects.toThrow();
});
it('should throw if toDB is called with a product that has no id', async () => {
sqlite.executeQuery = vi.fn().mockResolvedValue({ rows: [] });
PURCHASE_MOCK.product.id = undefined;
expect(service.insert(PURCHASE_MOCK)).rejects.toThrow();
await expect(service.insert(PURCHASE_MOCK)).rejects.toThrow();
});
});
});

View File

View File

@@ -0,0 +1,31 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Home } from './home';
import { Sqlite } from '../../services/sqlite';
import { provideTranslateService } from '@ngx-translate/core';
import { BottomNavigationBar } from '../../components/bottom-navigation-bar/bottom-navigation-bar';
describe('Home', () => {
let component: Home;
let fixture: ComponentFixture<Home>;
beforeEach(async () => {
TestBed.overrideComponent(BottomNavigationBar, {
set: {template: ``}
});
await TestBed.configureTestingModule({
imports: [Home],
providers: [{ provide: Sqlite, useValue: {} }, provideTranslateService({})],
}).compileComponents();
fixture = TestBed.createComponent(Home);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-home',
imports: [],
templateUrl: './home.html',
styles: ``,
})
export class Home {
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ChainEditPage } from './chain-edit-page';
import { ActivatedRoute } from '@angular/router';
import { of } from 'rxjs';
import { Chain } from '../../../../models/Chain';
import { ChainSettings } from '../../../../services/chain-settings';
import { provideTranslateService } from '@ngx-translate/core';
describe('ChainEditPage', () => {
let component: ChainEditPage;
let fixture: ComponentFixture<ChainEditPage>;
let activatedRoute: Partial<ActivatedRoute>;
beforeEach(async () => {
activatedRoute = {
data: of({chain: new Chain('Mock', '', 1)}),
};
await TestBed.configureTestingModule({
imports: [ChainEditPage],
providers: [
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: ChainSettings, useValue: {} },
provideTranslateService()
],
}).compileComponents();
fixture = TestBed.createComponent(ChainEditPage);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

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

View File

@@ -0,0 +1,8 @@
@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-image-uploader (file)="updateFileImage($event)" [fileIn]="form.controls.image.value"></app-image-uploader>
</form>

View File

@@ -0,0 +1,49 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ChainForm } from './chain-form';
import { provideTranslateService } from '@ngx-translate/core';
import { Component } from '@angular/core';
import { ImageUploader } from '../../../../components/image-uploader/image-uploader';
import { By } from '@angular/platform-browser';
@Component({
template: ``
})
class ImageUploaderMock extends ImageUploader {
}
describe('ChainForm', () => {
let component: ChainForm;
let fixture: ComponentFixture<ChainForm>;
beforeEach(async () => {
TestBed.overrideComponent(ImageUploader, {
remove: { imports: [ImageUploader] },
add: { imports: [ImageUploaderMock] },
});
await TestBed.configureTestingModule({
imports: [ChainForm],
providers: [provideTranslateService()]
})
.compileComponents();
fixture = TestBed.createComponent(ChainForm);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should populate form with emitted files', async () => {
const FILE_MOCK = new File([], 'mock');
const patchValueSpy = vi.spyOn(component.form().controls.image, 'patchValue');
const imageUploaderComp: ImageUploaderMock = fixture.debugElement.query(By.css('app-image-uploader')).componentInstance;
imageUploaderComp.file.emit(FILE_MOCK);
await fixture.whenStable();
expect(patchValueSpy).toHaveBeenCalledExactlyOnceWith(FILE_MOCK);
});
});

View File

@@ -0,0 +1,44 @@
import { ChangeDetectorRef, Component, inject, input, OnDestroy, OnInit } from '@angular/core';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { ImageUploader } from '../../../../components/image-uploader/image-uploader';
import { ReactiveFormsModule } from '@angular/forms';
import { ChainFormGroup } from '../chain-formgroup';
import { TranslatePipe } from '@ngx-translate/core';
import { UpperfirstPipe } from '../../../../pipes/upperfirst-pipe';
import { Subscription } from 'rxjs';
@Component({
selector: 'app-chain-form',
imports: [
ImageUploader,
MatFormFieldModule,
MatInputModule,
ReactiveFormsModule,
TranslatePipe,
UpperfirstPipe,
],
templateUrl: './chain-form.html',
styles: ``,
})
export class ChainForm implements OnInit, OnDestroy {
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,12 @@
import { FormControl, FormGroup, Validators } from '@angular/forms';
export class ChainFormGroup extends FormGroup<{name: FormControl<string|null>, image: FormControl<File|null>}> {
constructor(
form = {
name: new FormControl('', Validators.required),
image: new FormControl<File | null>(null),
},
) {
super(form);
}
}

View File

@@ -0,0 +1,61 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ChainList } from './chain-list';
import { ActivatedRoute, Router } from '@angular/router';
import { BehaviorSubject } from 'rxjs';
import { Chain } from '../../../../models/Chain';
import { By } from '@angular/platform-browser';
const ADD_PATH = ['settings', 'chains', 'add'];
const EDIT_PATH = ['settings', 'chains', 'edit'];
describe('ChainList', () => {
let component: ChainList;
let fixture: ComponentFixture<ChainList>;
let activatedRoute: Partial<ActivatedRoute>;
let router: Partial<Router>;
let EDIT_PATH_MOCK: string[];
const CHAIN_MOCK = new Chain('Mock', '', 1);
const dataSubject = new BehaviorSubject({ chains: [CHAIN_MOCK] });
beforeEach(async () => {
activatedRoute = {
data: dataSubject,
};
router = {
navigate: vi.fn(),
};
EDIT_PATH_MOCK = [...EDIT_PATH, String(CHAIN_MOCK.id)];
await TestBed.configureTestingModule({
imports: [ChainList],
providers: [
{ provide: ActivatedRoute, useValue: activatedRoute },
{ provide: Router, useValue: router },
],
}).compileComponents();
fixture = TestBed.createComponent(ChainList);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should navigate to edit on edit action', () => {
const editBtn = fixture.debugElement.query(By.css('[matIconButton]'));
editBtn.triggerEventHandler('click');
expect(router.navigate).toHaveBeenCalledExactlyOnceWith(EDIT_PATH_MOCK);
});
it('should navigate to add on add btn click', () => {
const addBtn = fixture.debugElement.query(By.css('app-floating-big-btn'));
addBtn.triggerEventHandler('bigClick');
expect(router.navigate).toHaveBeenCalledExactlyOnceWith(ADD_PATH);
});
});

View File

@@ -0,0 +1,40 @@
import { Component, inject } from '@angular/core';
import { SimpleListWActions } from '../../../../components/simple-list-w-actions/simple-list-w-actions';
import { ActivatedRoute, Router } from '@angular/router';
import { map } from 'rxjs';
import { SimpleListItem } from '../../../../components/simple-list-w-actions/SimpleListItem';
import { SimpleListItemAction } from '../../../../components/simple-list-w-actions/SimpleListItemAction';
import { Chain } from '../../../../models/Chain';
import { AsyncPipe } from '@angular/common';
import { FloatingBigBtn } from '../../../../components/floating-big-btn/floating-big-btn';
import { SettingsBaseList } from '../../../../components/settings-base-list/settings-base-list';
@Component({
selector: 'app-chain-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 ChainList extends SettingsBaseList {
private readonly router = inject(Router);
protected readonly activatedRoute = inject(ActivatedRoute);
data$ = this.activatedRoute.data.pipe(
map((data) =>
(<Chain[]>data['chains']).map(
(c, i) =>
new SimpleListItem(String(c.id), c.name ?? '', [
new SimpleListItemAction('edit', 'edit'),
]),
),
),
);
protected edit(action: { action: string; subject: string }) {
this.router.navigate(['settings', 'chains', 'edit', action.subject]);
}
protected add() {
this.router.navigate(['settings', 'chains', 'add']);
}
}

View File

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

View File

@@ -0,0 +1,4 @@
:host {
display: block;
height: 100%;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EstablishmentAddPage } from './establishment-add-page';
import { EstablishmentSettings } from '../../../../services/establishment-settings';
import { provideTranslateService } from '@ngx-translate/core';
import { ChainDAO } from '../../../../dao/ChainDAO';
describe('EstablishmentAddPage', () => {
let component: EstablishmentAddPage;
let fixture: ComponentFixture<EstablishmentAddPage>;
let chainDAO: Partial<ChainDAO>;
beforeEach(async () => {
chainDAO = {
findAll: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [EstablishmentAddPage],
providers: [
{ provide: ChainDAO, useValue: chainDAO },
{ provide: EstablishmentSettings, useValue: {} },
provideTranslateService(),
],
}).compileComponents();
fixture = TestBed.createComponent(EstablishmentAddPage);
component = fixture.componentInstance;
await fixture.whenStable();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
:host {
display: flex;
flex-direction: column;
height: 100%;
}
h3 {
margin-top: 0;
}

Some files were not shown because too many files have changed in this diff Show More