Ver código fonte

ajout des fichiers de base present sur sotec

master
trauchessec 2 semanas atrás
pai
commit
0155e9b7aa
91 arquivos alterados com 6041 adições e 2440 exclusões
  1. +11
    -4
      angular.json
  2. +1811
    -2057
      package-lock.json
  3. +26
    -12
      package.json
  4. +5
    -340
      src/app/app.html
  5. +51
    -0
      src/app/app.routes.ts
  6. +31
    -5
      src/app/app.ts
  7. +4
    -0
      src/app/config/authority.constants.ts
  8. +20
    -0
      src/app/config/datepicker-adapter.ts
  9. +13
    -0
      src/app/config/dayjs.ts
  10. +3
    -0
      src/app/config/error.constants.ts
  11. +83
    -0
      src/app/config/font-awesome-icons.ts
  12. +2
    -0
      src/app/config/input.constants.ts
  13. +9
    -0
      src/app/config/language.constants.ts
  14. +5
    -0
      src/app/config/navigation.constants.ts
  15. +3
    -0
      src/app/config/pagination.constants.ts
  16. +20
    -0
      src/app/config/translation.config.ts
  17. +15
    -0
      src/app/config/uib-pagination.config.ts
  18. +12
    -0
      src/app/core/auth/account.model.ts
  19. +250
    -0
      src/app/core/auth/account.service.spec.ts
  20. +95
    -0
      src/app/core/auth/account.service.ts
  21. +81
    -0
      src/app/core/auth/auth-jwt.service.spec.ts
  22. +40
    -0
      src/app/core/auth/auth-jwt.service.ts
  23. +53
    -0
      src/app/core/auth/state-storage.service.ts
  24. +33
    -0
      src/app/core/auth/user-route-access.service.ts
  25. +40
    -0
      src/app/core/config/application-config.service.spec.ts
  26. +28
    -0
      src/app/core/config/application-config.service.ts
  27. +29
    -0
      src/app/core/interceptor/auth-expired.interceptor.ts
  28. +29
    -0
      src/app/core/interceptor/auth.interceptor.ts
  29. +23
    -0
      src/app/core/interceptor/error-handler.interceptor.ts
  30. +30
    -0
      src/app/core/interceptor/index.ts
  31. +38
    -0
      src/app/core/interceptor/notification.interceptor.ts
  32. +17
    -0
      src/app/core/request/request-util.ts
  33. +11
    -0
      src/app/core/request/request.model.ts
  34. +285
    -0
      src/app/core/util/alert.service.spec.ts
  35. +89
    -0
      src/app/core/util/alert.service.ts
  36. +33
    -0
      src/app/core/util/data-util.service.spec.ts
  37. +131
    -0
      src/app/core/util/data-util.service.ts
  38. +84
    -0
      src/app/core/util/event-manager.service.spec.ts
  39. +66
    -0
      src/app/core/util/event-manager.service.ts
  40. +18
    -0
      src/app/core/util/operators.spec.ts
  41. +9
    -0
      src/app/core/util/operators.ts
  42. +66
    -0
      src/app/core/util/parse-links.service.spec.ts
  43. +59
    -0
      src/app/core/util/parse-links.service.ts
  44. +291
    -0
      src/app/data/mock-data.ts
  45. +28
    -0
      src/app/models/data.model.ts
  46. +7
    -0
      src/app/pages/login/login.model.ts
  47. +23
    -0
      src/app/pages/login/login.service.ts
  48. +125
    -0
      src/app/services/data.service.ts
  49. +11
    -0
      src/app/shared/alert/alert-error.component.html
  50. +159
    -0
      src/app/shared/alert/alert-error.component.spec.ts
  51. +132
    -0
      src/app/shared/alert/alert-error.component.ts
  52. +7
    -0
      src/app/shared/alert/alert-error.model.ts
  53. +11
    -0
      src/app/shared/alert/alert.component.html
  54. +44
    -0
      src/app/shared/alert/alert.component.spec.ts
  55. +36
    -0
      src/app/shared/alert/alert.component.ts
  56. +96
    -0
      src/app/shared/auth/has-any-authority.directive.spec.ts
  57. +38
    -0
      src/app/shared/auth/has-any-authority.directive.ts
  58. +15
    -0
      src/app/shared/date/duration.pipe.ts
  59. +19
    -0
      src/app/shared/date/format-medium-date.pipe.spec.ts
  60. +12
    -0
      src/app/shared/date/format-medium-date.pipe.ts
  61. +19
    -0
      src/app/shared/date/format-medium-datetime.pipe.spec.ts
  62. +12
    -0
      src/app/shared/date/format-medium-datetime.pipe.ts
  63. +3
    -0
      src/app/shared/date/index.ts
  64. +20
    -0
      src/app/shared/filter/filter.component.html
  65. +20
    -0
      src/app/shared/filter/filter.component.ts
  66. +242
    -0
      src/app/shared/filter/filter.model.spec.ts
  67. +158
    -0
      src/app/shared/filter/filter.model.ts
  68. +2
    -0
      src/app/shared/filter/index.ts
  69. +16
    -0
      src/app/shared/language/find-language-from-key.pipe.ts
  70. +2
    -0
      src/app/shared/language/index.ts
  71. +35
    -0
      src/app/shared/language/translate.directive.spec.ts
  72. +52
    -0
      src/app/shared/language/translate.directive.ts
  73. +32
    -0
      src/app/shared/language/translation.module.ts
  74. +1
    -0
      src/app/shared/pagination/index.ts
  75. +71
    -0
      src/app/shared/pagination/item-count.component.spec.ts
  76. +42
    -0
      src/app/shared/pagination/item-count.component.ts
  77. +28
    -0
      src/app/shared/shared.module.ts
  78. +4
    -0
      src/app/shared/sort/index.ts
  79. +119
    -0
      src/app/shared/sort/sort-by.directive.spec.ts
  80. +41
    -0
      src/app/shared/sort/sort-by.directive.ts
  81. +10
    -0
      src/app/shared/sort/sort-state.ts
  82. +86
    -0
      src/app/shared/sort/sort.directive.spec.ts
  83. +19
    -0
      src/app/shared/sort/sort.directive.ts
  84. +46
    -0
      src/app/shared/sort/sort.service.spec.ts
  85. +39
    -0
      src/app/shared/sort/sort.service.ts
  86. +7
    -0
      src/declarations.d.ts
  87. +2
    -0
      src/index.html
  88. +11
    -4
      src/main.ts
  89. +70
    -1
      src/styles.scss
  90. +5
    -5
      tsconfig.app.json
  91. +12
    -12
      tsconfig.json

+ 11
- 4
angular.json Ver arquivo

@@ -17,6 +17,8 @@
"build": {
"builder": "@angular/build:application",
"options": {
"outputPath": "dist/chatbot",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
@@ -31,7 +33,8 @@
],
"styles": [
"src/styles.scss"
]
],
"scripts": []
},
"configurations": {
"production": {
@@ -43,8 +46,8 @@
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "8kB"
"maximumWarning": "2kB",
"maximumError": "4kB"
}
],
"outputHashing": "all"
@@ -89,10 +92,14 @@
],
"styles": [
"src/styles.scss"
]
],
"scripts": []
}
}
}
}
},
"cli": {
"analytics": false
}
}

+ 1811
- 2057
package-lock.json
Diferenças do arquivo suprimidas por serem muito extensas
Ver arquivo


+ 26
- 12
package.json Ver arquivo

@@ -20,27 +20,41 @@
},
"private": true,
"dependencies": {
"@angular/common": "^20.1.0",
"@angular/compiler": "^20.1.0",
"@angular/core": "^20.1.0",
"@angular/forms": "^20.1.0",
"@angular/platform-browser": "^20.1.0",
"@angular/router": "^20.1.0",
"@angular/animations": "19.2.9",
"@angular/cdk": "19.2.9",
"@angular/common": "19.2.9",
"@angular/compiler": "19.2.9",
"@angular/core": "19.2.9",
"@angular/forms": "19.2.9",
"@angular/localize": "19.2.9",
"@angular/material": "19.2.9",
"@angular/platform-browser": "19.2.9",
"@angular/platform-browser-dynamic": "19.2.9",
"@angular/router": "19.2.9",
"@fortawesome/angular-fontawesome": "1.0.0",
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@ng-bootstrap/ng-bootstrap": "^18.0.0",
"@ngx-translate/core": "^17.0.0",
"@ngx-translate/http-loader": "^16.0.1",
"buffer": "^6.0.3",
"dayjs": "^1.11.19",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"tslib": "^2.7.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular/build": "^20.1.5",
"@angular/cli": "^20.1.5",
"@angular/compiler-cli": "^20.1.0",
"@angular/build": "19.2.9",
"@angular/cli": "19.2.9",
"@angular/compiler-cli": "19.2.9",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.8.0",
"@types/node": "^22.10.2",
"jasmine-core": "~5.4.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.8.2"
"typescript": "~5.7.2"
}
}

+ 5
- 340
src/app/app.html Ver arquivo

@@ -1,341 +1,6 @@
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * 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://twitter.com/angular"
aria-label="Twitter"
target="_blank"
rel="noopener"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
alt="Twitter"
>
<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 * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->

<div class="main-layout">

<main class="content-area">
<router-outlet></router-outlet>
</main>
</div>

+ 51
- 0
src/app/app.routes.ts Ver arquivo

@@ -0,0 +1,51 @@
import { Routes } from '@angular/router';

import { Dashboard } from './pages/dashboard/dashboard';
import { Sidebar } from './components/sidebar/sidebar';
import {Login} from './pages/login/login';
// import { UserRouteAccessService } from './core/auth/user-route-access.service'; // Si besoin
/*
// Routes d'erreur simples
const errorRoute: Routes = [
{
path: '404',
loadComponent: () => import('./layouts/error/error.component').then(c => c.ErrorComponent),
title: 'Page non trouvée',
},
];*/


const routes: Routes = [
{
path: '',
pathMatch: 'full',
redirectTo: 'dashboard',
},

{
path: '',
component: Sidebar,
outlet: 'sidebar',
},


{
path: 'login',
component: Login,
title: 'Connexion',
},
{
path: '',
children: [
{
path: 'dashboard',
component: Dashboard,
title: 'Tableau de Bord IA',
},
]
},

//...errorRoute,
];

export default routes;

+ 31
- 5
src/app/app.ts Ver arquivo

@@ -1,11 +1,37 @@
import { Component, signal } from '@angular/core';

import { Component, OnInit } from '@angular/core';
import { Router, RouterOutlet, NavigationEnd } from '@angular/router';
import { CommonModule } from '@angular/common';
import { filter } from 'rxjs/operators';

@Component({
selector: 'app-root',
imports: [],
standalone: true,
imports: [RouterOutlet, CommonModule],
templateUrl: './app.html',
styleUrl: './app.scss'
styleUrls: ['./app.scss'],
})
export class App {
protected readonly title = signal('chatbot');
export class AppComponent implements OnInit {
title = 'ai-dashboard';
showSidebar: boolean = false;

private readonly routesWithoutSidebar = ['/login', '/register', '/forgot-password'];

constructor(private router: Router) {}

ngOnInit(): void {
this.checkIfShouldShowSidebar(this.router.url);

this.router.events
.pipe(filter(event => event instanceof NavigationEnd))
.subscribe((event: NavigationEnd) => {
this.checkIfShouldShowSidebar(event.url);
});
}

private checkIfShouldShowSidebar(url: string): void {
this.showSidebar = !this.routesWithoutSidebar.some(route =>
url.startsWith(route) || url === route
);
}
}

+ 4
- 0
src/app/config/authority.constants.ts Ver arquivo

@@ -0,0 +1,4 @@
export enum Authority {
ADMIN = 'ROLE_ADMIN',
USER = 'ROLE_USER',
}

+ 20
- 0
src/app/config/datepicker-adapter.ts Ver arquivo

@@ -0,0 +1,20 @@
/**
* Angular bootstrap Date adapter
*/
import { Injectable } from '@angular/core';
import { NgbDateAdapter, NgbDateStruct } from '@ng-bootstrap/ng-bootstrap';
import dayjs from 'dayjs/esm';

@Injectable()
export class NgbDateDayjsAdapter extends NgbDateAdapter<dayjs.Dayjs> {
fromModel(date: dayjs.Dayjs | null): NgbDateStruct | null {
if (date && dayjs.isDayjs(date) && date.isValid()) {
return { year: date.year(), month: date.month() + 1, day: date.date() };
}
return null;
}

toModel(date: NgbDateStruct | null): dayjs.Dayjs | null {
return date ? dayjs(`${date.year}-${date.month}-${date.day}`) : null;
}
}

+ 13
- 0
src/app/config/dayjs.ts Ver arquivo

@@ -0,0 +1,13 @@
import dayjs from 'dayjs/esm';
import customParseFormat from 'dayjs/esm/plugin/customParseFormat';
import duration from 'dayjs/esm/plugin/duration';
import relativeTime from 'dayjs/esm/plugin/relativeTime';

// jhipster-needle-i18n-language-dayjs-imports - JHipster will import languages from dayjs here
import 'dayjs/esm/locale/fr';
import 'dayjs/esm/locale/pt';

// DAYJS CONFIGURATION
dayjs.extend(customParseFormat);
dayjs.extend(duration);
dayjs.extend(relativeTime);

+ 3
- 0
src/app/config/error.constants.ts Ver arquivo

@@ -0,0 +1,3 @@
export const PROBLEM_BASE_URL = 'https://www.jhipster.tech/problem';
export const EMAIL_ALREADY_USED_TYPE = `${PROBLEM_BASE_URL}/email-already-used`;
export const LOGIN_ALREADY_USED_TYPE = `${PROBLEM_BASE_URL}/login-already-used`;

+ 83
- 0
src/app/config/font-awesome-icons.ts Ver arquivo

@@ -0,0 +1,83 @@
import {
faArrowLeft,
faAsterisk,
faBan,
faBars,
faBell,
faBook,
faCalendarAlt,
faCheck,
faCloud,
faCogs,
faDatabase,
faEye,
faFlag,
faHeart,
faHome,
faList,
faLock,
faPencilAlt,
faPlus,
faRoad,
faSave,
faSearch,
faSignInAlt,
faSignOutAlt,
faSort,
faSortDown,
faSortUp,
faSync,
faTachometerAlt,
faTasks,
faThList,
faTimes,
faTrashAlt,
faUser,
faUserPlus,
faUsers,
faUsersCog,
faWrench,
// jhipster-needle-add-icon-import
} from '@fortawesome/free-solid-svg-icons';

export const fontAwesomeIcons = [
faArrowLeft,
faAsterisk,
faBan,
faBars,
faBell,
faBook,
faCalendarAlt,
faCheck,
faCloud,
faCogs,
faDatabase,
faEye,
faFlag,
faHeart,
faHome,
faList,
faLock,
faPencilAlt,
faPlus,
faRoad,
faSave,
faSearch,
faSignOutAlt,
faSignInAlt,
faSort,
faSortDown,
faSortUp,
faSync,
faTachometerAlt,
faTasks,
faThList,
faTimes,
faTrashAlt,
faUser,
faUserPlus,
faUsers,
faUsersCog,
faWrench,
// jhipster-needle-add-icon-import
];

+ 2
- 0
src/app/config/input.constants.ts Ver arquivo

@@ -0,0 +1,2 @@
export const DATE_FORMAT = 'YYYY-MM-DD';
export const DATE_TIME_FORMAT = 'YYYY-MM-DDTHH:mm';

+ 9
- 0
src/app/config/language.constants.ts Ver arquivo

@@ -0,0 +1,9 @@
/*
Languages codes are ISO_639-1 codes, see http://en.wikipedia.org/wiki/List_of_ISO_639-1_codes
They are written in English to avoid character encoding issues (not a perfect solution)
*/
export const LANGUAGES: string[] = [
'fr',
'pt-pt',
// jhipster-needle-i18n-language-constant - JHipster will add/remove languages in this array
];

+ 5
- 0
src/app/config/navigation.constants.ts Ver arquivo

@@ -0,0 +1,5 @@
export const ASC = 'asc';
export const DESC = 'desc';
export const SORT = 'sort';
export const ITEM_DELETED_EVENT = 'deleted';
export const DEFAULT_SORT_DATA = 'defaultSort';

+ 3
- 0
src/app/config/pagination.constants.ts Ver arquivo

@@ -0,0 +1,3 @@
export const TOTAL_COUNT_RESPONSE_HEADER = 'X-Total-Count';
export const PAGE_HEADER = 'page';
export const ITEMS_PER_PAGE = 20;

+ 20
- 0
src/app/config/translation.config.ts Ver arquivo

@@ -0,0 +1,20 @@
import { MissingTranslationHandler, MissingTranslationHandlerParams, TranslateLoader } from '@ngx-translate/core';
import { HttpClient } from '@angular/common/http';
import { TranslateHttpLoader } from '@ngx-translate/http-loader';

export const translationNotFoundMessage = 'translation-not-found';

export class MissingTranslationHandlerImpl implements MissingTranslationHandler {
handle(params: MissingTranslationHandlerParams): string {
const { key } = params;
return `${translationNotFoundMessage}[${key}]`;
}
}

export function translatePartialLoader(http: HttpClient): TranslateLoader {
return new TranslateHttpLoader(http, 'i18n/', `.json?_=${I18N_HASH}`);
}

export function missingTranslationHandler(): MissingTranslationHandler {
return new MissingTranslationHandlerImpl();
}

+ 15
- 0
src/app/config/uib-pagination.config.ts Ver arquivo

@@ -0,0 +1,15 @@
import { Injectable, inject } from '@angular/core';
import { NgbPaginationConfig } from '@ng-bootstrap/ng-bootstrap';
import {ITEMS_PER_PAGE} from './pagination.constants';


@Injectable({ providedIn: 'root' })
export class PaginationConfig {
private readonly config = inject(NgbPaginationConfig);
constructor() {
this.config.boundaryLinks = true;
this.config.maxSize = 5;
this.config.pageSize = ITEMS_PER_PAGE;
this.config.size = 'sm';
}
}

+ 12
- 0
src/app/core/auth/account.model.ts Ver arquivo

@@ -0,0 +1,12 @@
export class Account {
constructor(
public activated: boolean,
public authorities: string[],
public email: string,
public firstName: string | null,
public langKey: string,
public lastName: string | null,
public login: string,
public imageUrl: string | null,
) {}
}

+ 250
- 0
src/app/core/auth/account.service.spec.ts Ver arquivo

@@ -0,0 +1,250 @@
jest.mock('app/core/auth/state-storage.service');

import { Router } from '@angular/router';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { InterpolatableTranslationObject, TranslateModule, TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';

import { Account } from 'app/core/auth/account.model';
import { Authority } from 'app/config/authority.constants';
import { StateStorageService } from 'app/core/auth/state-storage.service';
import { ApplicationConfigService } from 'app/core/config/application-config.service';

import { AccountService } from './account.service';

function accountWithAuthorities(authorities: string[]): Account {
return {
activated: true,
authorities,
email: '',
firstName: '',
langKey: '',
lastName: '',
login: '',
imageUrl: '',
};
}

const mockFn = (value: string | null): jest.Mock<string | null> => jest.fn(() => value);

describe('Account Service', () => {
let service: AccountService;
let applicationConfigService: ApplicationConfigService;
let httpMock: HttpTestingController;
let mockStorageService: StateStorageService;
let mockRouter: Router;
let mockTranslateService: TranslateService;

beforeEach(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
providers: [provideHttpClient(), provideHttpClientTesting(), StateStorageService],
});

service = TestBed.inject(AccountService);
applicationConfigService = TestBed.inject(ApplicationConfigService);
httpMock = TestBed.inject(HttpTestingController);
mockStorageService = TestBed.inject(StateStorageService);
mockRouter = TestBed.inject(Router);
jest.spyOn(mockRouter, 'navigateByUrl').mockImplementation(() => Promise.resolve(true));

mockTranslateService = TestBed.inject(TranslateService);
jest.spyOn(mockTranslateService, 'use').mockImplementation(() => of({} as InterpolatableTranslationObject));
});

afterEach(() => {
httpMock.verify();
});

describe('save', () => {
it('should call account saving endpoint with correct values', () => {
// GIVEN
const account = accountWithAuthorities([]);

// WHEN
service.save(account).subscribe();
const testRequest = httpMock.expectOne({ method: 'POST', url: applicationConfigService.getEndpointFor('api/account') });
testRequest.flush({});

// THEN
expect(testRequest.request.body).toEqual(account);
});
});

describe('authenticate', () => {
it('authenticationState should emit null if input is null', () => {
// GIVEN
let userIdentity: Account | null = accountWithAuthorities([]);
service.getAuthenticationState().subscribe(account => (userIdentity = account));

// WHEN
service.authenticate(null);

// THEN
expect(userIdentity).toBeNull();
expect(service.isAuthenticated()).toBe(false);
});

it('authenticationState should emit the same account as was in input parameter', () => {
// GIVEN
const expectedResult = accountWithAuthorities([]);
let userIdentity: Account | null = null;
service.getAuthenticationState().subscribe(account => (userIdentity = account));

// WHEN
service.authenticate(expectedResult);

// THEN
expect(userIdentity).toEqual(expectedResult);
expect(service.isAuthenticated()).toBe(true);
});
});

describe('identity', () => {
it('should call /account only once if last call have not returned', () => {
// When I call
service.identity().subscribe();
// Once more
service.identity().subscribe();
// Then there is only request
httpMock.expectOne({ method: 'GET' });
});

it('should call /account only once if not logged out after first authentication and should call /account again if user has logged out', () => {
// Given the user is authenticated
service.identity().subscribe();
httpMock.expectOne({ method: 'GET' }).flush({});

// When I call
service.identity().subscribe();

// Then there is no second request
httpMock.expectNone({ method: 'GET' });

// When I log out
service.authenticate(null);
// and then call
service.identity().subscribe();

// Then there is a new request
httpMock.expectOne({ method: 'GET' });
});

describe('should change the language on authentication if necessary', () => {
it('should change language if user has not changed language manually', () => {
// GIVEN
mockStorageService.getLocale = mockFn(null);

// WHEN
service.identity().subscribe();
httpMock.expectOne({ method: 'GET' }).flush({ ...accountWithAuthorities([]), langKey: 'accountLang' });

// THEN
expect(mockTranslateService.use).toHaveBeenCalledWith('accountLang');
});

it('should not change language if user has changed language manually', () => {
// GIVEN
mockStorageService.getLocale = mockFn('sessionLang');

// WHEN
service.identity().subscribe();
httpMock.expectOne({ method: 'GET' }).flush({ ...accountWithAuthorities([]), langKey: 'accountLang' });

// THEN
expect(mockTranslateService.use).not.toHaveBeenCalled();
});
});

describe('navigateToStoredUrl', () => {
it('should navigate to the previous stored url post successful authentication', () => {
// GIVEN
mockStorageService.getUrl = mockFn('admin/users?page=0');

// WHEN
service.identity().subscribe();
httpMock.expectOne({ method: 'GET' }).flush({});

// THEN
expect(mockStorageService.getUrl).toHaveBeenCalledTimes(1);
expect(mockStorageService.clearUrl).toHaveBeenCalledTimes(1);
expect(mockRouter.navigateByUrl).toHaveBeenCalledWith('admin/users?page=0');
});

it('should not navigate to the previous stored url when authentication fails', () => {
// WHEN
service.identity().subscribe();
httpMock.expectOne({ method: 'GET' }).error(new ProgressEvent(''));

// THEN
expect(mockStorageService.getUrl).not.toHaveBeenCalled();
expect(mockStorageService.clearUrl).not.toHaveBeenCalled();
expect(mockRouter.navigateByUrl).not.toHaveBeenCalled();
});

it('should not navigate to the previous stored url when no such url exists post successful authentication', () => {
// GIVEN
mockStorageService.getUrl = mockFn(null);

// WHEN
service.identity().subscribe();
httpMock.expectOne({ method: 'GET' }).flush({});

// THEN
expect(mockStorageService.getUrl).toHaveBeenCalledTimes(1);
expect(mockStorageService.clearUrl).not.toHaveBeenCalled();
expect(mockRouter.navigateByUrl).not.toHaveBeenCalled();
});
});
});

describe('hasAnyAuthority', () => {
describe('hasAnyAuthority string parameter', () => {
it('should return false if user is not logged', () => {
const hasAuthority = service.hasAnyAuthority(Authority.USER);
expect(hasAuthority).toBe(false);
});

it('should return false if user is logged and has not authority', () => {
service.authenticate(accountWithAuthorities([Authority.USER]));

const hasAuthority = service.hasAnyAuthority(Authority.ADMIN);

expect(hasAuthority).toBe(false);
});

it('should return true if user is logged and has authority', () => {
service.authenticate(accountWithAuthorities([Authority.USER]));

const hasAuthority = service.hasAnyAuthority(Authority.USER);

expect(hasAuthority).toBe(true);
});
});

describe('hasAnyAuthority array parameter', () => {
it('should return false if user is not logged', () => {
const hasAuthority = service.hasAnyAuthority([Authority.USER]);
expect(hasAuthority).toBeFalsy();
});

it('should return false if user is logged and has not authority', () => {
service.authenticate(accountWithAuthorities([Authority.USER]));

const hasAuthority = service.hasAnyAuthority([Authority.ADMIN]);

expect(hasAuthority).toBe(false);
});

it('should return true if user is logged and has authority', () => {
service.authenticate(accountWithAuthorities([Authority.USER]));

const hasAuthority = service.hasAnyAuthority([Authority.USER, Authority.ADMIN]);

expect(hasAuthority).toBe(true);
});
});
});
});

+ 95
- 0
src/app/core/auth/account.service.ts Ver arquivo

@@ -0,0 +1,95 @@
import { Injectable, Signal, inject, signal } from '@angular/core';
import { Router } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { TranslateService } from '@ngx-translate/core';
import { Observable, ReplaySubject, of } from 'rxjs';
import { catchError, shareReplay, tap } from 'rxjs/operators';

import { ApplicationConfigService } from '../config/application-config.service';
import {Account} from './account.model';
import {StateStorageService} from './state-storage.service';

@Injectable({ providedIn: 'root' })
export class AccountService {
private readonly userIdentity = signal<Account | null>(null);
private readonly authenticationState = new ReplaySubject<Account | null>(1);
private accountCache$?: Observable<Account> | null;

private readonly translateService = inject(TranslateService, { optional: true });
private readonly http = inject(HttpClient);
private readonly stateStorageService = inject(StateStorageService);
private readonly router = inject(Router);
private readonly applicationConfigService = inject(ApplicationConfigService);

save(account: Account): Observable<{}> {
return this.http.post(this.applicationConfigService.getEndpointFor('api/account'), account);
}

authenticate(identity: Account | null): void {
this.userIdentity.set(identity);
this.authenticationState.next(this.userIdentity());
if (!identity) {
this.accountCache$ = null;
}
}

trackCurrentAccount(): Signal<Account | null> {
return this.userIdentity.asReadonly();
}

hasAnyAuthority(authorities: string[] | string): boolean {
const userIdentity = this.userIdentity();
if (!userIdentity) {
return false;
}
if (!Array.isArray(authorities)) {
authorities = [authorities];
}
return userIdentity.authorities.some((authority: string) => authorities.includes(authority));
}

identity(force?: boolean): Observable<Account | null> {
if (!this.accountCache$ || force) {
this.accountCache$ = this.fetch().pipe(
tap((account: Account) => {
this.authenticate(account);

// After retrieve the account info, the language will be changed to
// the user's preferred language configured in the account setting
// unless user have chosen another language in the current session
if (!this.stateStorageService.getLocale()) {
if (this.translateService){

this.translateService.use(account.langKey);}
}

this.navigateToStoredUrl();
}),
shareReplay(),
);
}
return this.accountCache$.pipe(catchError(() => of(null)));
}

isAuthenticated(): boolean {
return this.userIdentity() !== null;
}

getAuthenticationState(): Observable<Account | null> {
return this.authenticationState.asObservable();
}

private fetch(): Observable<Account> {
return this.http.get<Account>(this.applicationConfigService.getEndpointFor('api/account'));
}

private navigateToStoredUrl(): void {
// previousState can be set in the authExpiredInterceptor and in the userRouteAccessService
// if login is successful, go to stored previousState and clear previousState
const previousUrl = this.stateStorageService.getUrl();
if (previousUrl) {
this.stateStorageService.clearUrl();
this.router.navigateByUrl(previousUrl);
}
}
}

+ 81
- 0
src/app/core/auth/auth-jwt.service.spec.ts Ver arquivo

@@ -0,0 +1,81 @@
import { TestBed } from '@angular/core/testing';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideHttpClient } from '@angular/common/http';
import { AuthServerProvider } from 'app/core/auth/auth-jwt.service';
import { StateStorageService } from './state-storage.service';

describe('Auth JWT', () => {
let service: AuthServerProvider;
let httpMock: HttpTestingController;
let mockStorageService: StateStorageService;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});

mockStorageService = TestBed.inject(StateStorageService);
httpMock = TestBed.inject(HttpTestingController);
service = TestBed.inject(AuthServerProvider);
});

describe('Get Token', () => {
it('should return empty token if not found in local storage nor session storage', () => {
const result = service.getToken();
expect(result).toEqual('');
});

it('should return token from session storage if local storage is empty', () => {
sessionStorage.setItem('jhi-authenticationToken', JSON.stringify('sessionStorageToken'));
const result = service.getToken();
expect(result).toEqual('sessionStorageToken');
});

it('should return token from localstorage storage', () => {
localStorage.setItem('jhi-authenticationToken', JSON.stringify('localStorageToken'));
const result = service.getToken();
expect(result).toEqual('localStorageToken');
});
});

describe('Login', () => {
it('should clear session storage and save in local storage when rememberMe is true', () => {
// GIVEN
mockStorageService.storeAuthenticationToken = jest.fn();

// WHEN
service.login({ username: 'John', password: '123', rememberMe: true }).subscribe();
httpMock.expectOne('api/authenticate').flush({ id_token: '1' });

// THEN
httpMock.verify();
expect(mockStorageService.storeAuthenticationToken).toHaveBeenCalledWith('1', true);
});

it('should clear local storage and save in session storage when rememberMe is false', () => {
// GIVEN
mockStorageService.storeAuthenticationToken = jest.fn();

// WHEN
service.login({ username: 'John', password: '123', rememberMe: false }).subscribe();
httpMock.expectOne('api/authenticate').flush({ id_token: '1' });

// THEN
httpMock.verify();
expect(mockStorageService.storeAuthenticationToken).toHaveBeenCalledWith('1', false);
});
});

describe('Logout', () => {
it('should clear storage', () => {
// GIVEN
mockStorageService.clearAuthenticationToken = jest.fn();

// WHEN
service.logout().subscribe();

// THEN
expect(mockStorageService.clearAuthenticationToken).toHaveBeenCalled();
});
});
});

+ 40
- 0
src/app/core/auth/auth-jwt.service.ts Ver arquivo

@@ -0,0 +1,40 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

import { ApplicationConfigService } from '../config/application-config.service';
import { StateStorageService } from './state-storage.service';
import {Login} from '../../pages/login/login.model';

type JwtToken = {
id_token: string;
};

@Injectable({ providedIn: 'root' })
export class AuthServerProvider {
private readonly http = inject(HttpClient);
private readonly stateStorageService = inject(StateStorageService);
private readonly applicationConfigService = inject(ApplicationConfigService);

getToken(): string {
return this.stateStorageService.getAuthenticationToken() ?? '';
}

login(credentials: Login): Observable<void> {
return this.http
.post<JwtToken>(this.applicationConfigService.getEndpointFor('api/authenticate'), credentials)
.pipe(map(response => this.authenticateSuccess(response, credentials.rememberMe)));
}

logout(): Observable<void> {
return new Observable(observer => {
this.stateStorageService.clearAuthenticationToken();
observer.complete();
});
}

private authenticateSuccess(response: JwtToken, rememberMe: boolean): void {
this.stateStorageService.storeAuthenticationToken(response.id_token, rememberMe);
}
}

+ 53
- 0
src/app/core/auth/state-storage.service.ts Ver arquivo

@@ -0,0 +1,53 @@
import { Injectable } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class StateStorageService {
private readonly previousUrlKey = 'previousUrl';
private readonly authenticationKey = 'jhi-authenticationToken';
private readonly localeKey = 'locale';

storeUrl(url: string): void {
sessionStorage.setItem(this.previousUrlKey, JSON.stringify(url));
}

getUrl(): string | null {
const previousUrl = sessionStorage.getItem(this.previousUrlKey);
return previousUrl ? (JSON.parse(previousUrl) as string | null) : previousUrl;
}

clearUrl(): void {
sessionStorage.removeItem(this.previousUrlKey);
}

storeAuthenticationToken(authenticationToken: string, rememberMe: boolean): void {
authenticationToken = JSON.stringify(authenticationToken);
this.clearAuthenticationToken();
if (rememberMe) {
localStorage.setItem(this.authenticationKey, authenticationToken);
} else {
sessionStorage.setItem(this.authenticationKey, authenticationToken);
}
}

getAuthenticationToken(): string | null {
const authenticationToken = localStorage.getItem(this.authenticationKey) ?? sessionStorage.getItem(this.authenticationKey);
return authenticationToken ? (JSON.parse(authenticationToken) as string | null) : authenticationToken;
}

clearAuthenticationToken(): void {
sessionStorage.removeItem(this.authenticationKey);
localStorage.removeItem(this.authenticationKey);
}

storeLocale(locale: string): void {
sessionStorage.setItem(this.localeKey, locale);
}

getLocale(): string | null {
return sessionStorage.getItem(this.localeKey);
}

clearLocale(): void {
sessionStorage.removeItem(this.localeKey);
}
}

+ 33
- 0
src/app/core/auth/user-route-access.service.ts Ver arquivo

@@ -0,0 +1,33 @@
import { inject, isDevMode } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivateFn, Router, RouterStateSnapshot } from '@angular/router';
import { map } from 'rxjs/operators';

import { StateStorageService } from './state-storage.service';
import {AccountService} from './account.service';

export const UserRouteAccessService: CanActivateFn = (next: ActivatedRouteSnapshot, state: RouterStateSnapshot) => {
const accountService = inject(AccountService);
const router = inject(Router);
const stateStorageService = inject(StateStorageService);
return accountService.identity().pipe(
map(account => {
if (account) {
const { authorities } = next.data;

if (!authorities || authorities.length === 0 || accountService.hasAnyAuthority(authorities)) {
return true;
}

if (isDevMode()) {
console.error('User does not have any of the required authorities:', authorities);
}
router.navigate(['accessdenied']);
return false;
}

stateStorageService.storeUrl(state.url);
router.navigate(['/login']);
return false;
}),
);
};

+ 40
- 0
src/app/core/config/application-config.service.spec.ts Ver arquivo

@@ -0,0 +1,40 @@
import { TestBed } from '@angular/core/testing';

import { ApplicationConfigService } from './application-config.service';

describe('ApplicationConfigService', () => {
let service: ApplicationConfigService;

beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(ApplicationConfigService);
});

it('should be created', () => {
expect(service).toBeTruthy();
});

describe('without prefix', () => {
it('should return correctly', () => {
expect(service.getEndpointFor('api')).toEqual('api');
});

it('should return correctly when passing microservice', () => {
expect(service.getEndpointFor('api', 'microservice')).toEqual('services/microservice/api');
});
});

describe('with prefix', () => {
beforeEach(() => {
service.setEndpointPrefix('prefix/');
});

it('should return correctly', () => {
expect(service.getEndpointFor('api')).toEqual('prefix/api');
});

it('should return correctly when passing microservice', () => {
expect(service.getEndpointFor('api', 'microservice')).toEqual('prefix/services/microservice/api');
});
});
});

+ 28
- 0
src/app/core/config/application-config.service.ts Ver arquivo

@@ -0,0 +1,28 @@
import { Injectable } from '@angular/core';

@Injectable({
providedIn: 'root',
})
export class ApplicationConfigService {
private endpointPrefix = '';
private microfrontend = false;

setEndpointPrefix(endpointPrefix: string): void {
this.endpointPrefix = endpointPrefix;
}

setMicrofrontend(microfrontend = true): void {
this.microfrontend = microfrontend;
}

isMicrofrontend(): boolean {
return this.microfrontend;
}

getEndpointFor(api: string, microservice?: string): string {
if (microservice) {
return `${this.endpointPrefix}services/${microservice}/${api}`;
}
return `${this.endpointPrefix}${api}`;
}
}

+ 29
- 0
src/app/core/interceptor/auth-expired.interceptor.ts Ver arquivo

@@ -0,0 +1,29 @@
import { Injectable, inject } from '@angular/core';
import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Router } from '@angular/router';
import {StateStorageService} from '../auth/state-storage.service';
import {LoginService} from '../../pages/login/login.service';


@Injectable()
export class AuthExpiredInterceptor implements HttpInterceptor {
private readonly loginService = inject(LoginService);
private readonly stateStorageService = inject(StateStorageService);
private readonly router = inject(Router);

intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return next.handle(request).pipe(
tap({
error: (err: HttpErrorResponse) => {
if (err.status === 401 && err.url && !err.url.includes('api/account')) {
this.stateStorageService.storeUrl(this.router.routerState.snapshot.url);
this.loginService.logout();
this.router.navigate(['/login']);
}
},
}),
);
}
}

+ 29
- 0
src/app/core/interceptor/auth.interceptor.ts Ver arquivo

@@ -0,0 +1,29 @@
import { Injectable, inject } from '@angular/core';
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Observable } from 'rxjs';

import { ApplicationConfigService } from '../config/application-config.service';
import {StateStorageService} from '../auth/state-storage.service';

@Injectable()
export class AuthInterceptor implements HttpInterceptor {
private readonly stateStorageService = inject(StateStorageService);
private readonly applicationConfigService = inject(ApplicationConfigService);

intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const serverApiUrl = this.applicationConfigService.getEndpointFor('');
if (!request.url || (request.url.startsWith('http') && !(serverApiUrl && request.url.startsWith(serverApiUrl)))) {
return next.handle(request);
}

const token: string | null = this.stateStorageService.getAuthenticationToken();
if (token) {
request = request.clone({
setHeaders: {
Authorization: `Bearer ${token}`,
},
});
}
return next.handle(request);
}
}

+ 23
- 0
src/app/core/interceptor/error-handler.interceptor.ts Ver arquivo

@@ -0,0 +1,23 @@
import { Injectable, inject } from '@angular/core';
import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import {EventManager, EventWithContent} from '../util/event-manager.service';


@Injectable()
export class ErrorHandlerInterceptor implements HttpInterceptor {
private readonly eventManager = inject(EventManager);

intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return next.handle(request).pipe(
tap({
error: (err: HttpErrorResponse) => {
if (!(err.status === 401 && (err.message === '' || err.url?.includes('api/account')))) {
this.eventManager.broadcast(new EventWithContent('villeveyracApp.httpError', err));
}
},
}),
);
}
}

+ 30
- 0
src/app/core/interceptor/index.ts Ver arquivo

@@ -0,0 +1,30 @@
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import {AuthInterceptor} from './auth.interceptor';
import {AuthExpiredInterceptor} from './auth-expired.interceptor';
import {ErrorHandlerInterceptor} from './error-handler.interceptor';
import {NotificationInterceptor} from './notification.interceptor';



export const httpInterceptorProviders = [
{
provide: HTTP_INTERCEPTORS,
useClass: AuthInterceptor,
multi: true,
},
{
provide: HTTP_INTERCEPTORS,
useClass: AuthExpiredInterceptor,
multi: true,
},
{
provide: HTTP_INTERCEPTORS,
useClass: ErrorHandlerInterceptor,
multi: true,
},
{
provide: HTTP_INTERCEPTORS,
useClass: NotificationInterceptor,
multi: true,
},
];

+ 38
- 0
src/app/core/interceptor/notification.interceptor.ts Ver arquivo

@@ -0,0 +1,38 @@
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest, HttpResponse } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import {AlertService} from '../util/alert.service';


@Injectable()
export class NotificationInterceptor implements HttpInterceptor {
private readonly alertService = inject(AlertService);

intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return next.handle(request).pipe(
tap((event: HttpEvent<any>) => {
if (event instanceof HttpResponse) {
let alert: string | null = null;
let alertParams: string | null = null;

for (const headerKey of event.headers.keys()) {
if (headerKey.toLowerCase().endsWith('app-alert')) {
alert = event.headers.get(headerKey);
} else if (headerKey.toLowerCase().endsWith('app-params')) {
alertParams = decodeURIComponent(event.headers.get(headerKey)!.replace(/\+/g, ' '));
}
}

if (alert) {
this.alertService.addAlert({
type: 'success',
translationKey: alert,
translationParams: { param: alertParams },
});
}
}
}),
);
}
}

+ 17
- 0
src/app/core/request/request-util.ts Ver arquivo

@@ -0,0 +1,17 @@
import { HttpParams } from '@angular/common/http';

export const createRequestOption = (req?: any): HttpParams => {
let options: HttpParams = new HttpParams();

if (req) {
Object.entries(req).forEach(([key, val]) => {
if (val !== undefined && val !== null) {
for (const value of [].concat(req[key]).filter(v => v !== '')) {
options = options.append(key, value);
}
}
});
}

return options;
};

+ 11
- 0
src/app/core/request/request.model.ts Ver arquivo

@@ -0,0 +1,11 @@
export interface Pagination {
page: number;
size: number;
sort: string[];
}

export interface Search {
query: string;
}

export interface SearchWithPagination extends Search, Pagination {}

+ 285
- 0
src/app/core/util/alert.service.spec.ts Ver arquivo

@@ -0,0 +1,285 @@
import { TestBed, inject } from '@angular/core/testing';
import { MissingTranslationHandler, TranslateModule, TranslateService } from '@ngx-translate/core';
import { missingTranslationHandler } from '../../config/translation.config';

import { Alert, AlertService } from './alert.service';

describe('Alert service test', () => {
describe('Alert Service Test', () => {
let extAlerts: Alert[];

beforeEach(() => {
TestBed.configureTestingModule({
imports: [
TranslateModule.forRoot({
missingTranslationHandler: {
provide: MissingTranslationHandler,
useFactory: missingTranslationHandler,
},
}),
],
});
const translateService = TestBed.inject(TranslateService);
translateService.setDefaultLang('en');
jest.useFakeTimers();
extAlerts = [];
});

it('should produce a proper alert object and fetch it', inject([AlertService], (service: AlertService) => {
expect(
service.addAlert({
type: 'success',
message: 'Hello Jhipster',
timeout: 3000,
toast: true,
position: 'top left',
}),
).toEqual(
expect.objectContaining({
type: 'success',
message: 'Hello Jhipster',
id: 0,
timeout: 3000,
toast: true,
position: 'top left',
} as Alert),
);

expect(service.get().length).toBe(1);
expect(service.get()[0]).toEqual(
expect.objectContaining({
type: 'success',
message: 'Hello Jhipster',
id: 0,
timeout: 3000,
toast: true,
position: 'top left',
} as Alert),
);
}));

it('should produce a proper alert object and add it to external alert objects array', inject(
[AlertService],
(service: AlertService) => {
expect(
service.addAlert(
{
type: 'success',
message: 'Hello Jhipster',
timeout: 3000,
toast: true,
position: 'top left',
},
extAlerts,
),
).toEqual(
expect.objectContaining({
type: 'success',
message: 'Hello Jhipster',
id: 0,
timeout: 3000,
toast: true,
position: 'top left',
} as Alert),
);

expect(extAlerts.length).toBe(1);
expect(extAlerts[0]).toEqual(
expect.objectContaining({
type: 'success',
message: 'Hello Jhipster',
id: 0,
timeout: 3000,
toast: true,
position: 'top left',
} as Alert),
);
},
));

it('should produce an alert object with correct id', inject([AlertService], (service: AlertService) => {
service.addAlert({ type: 'info', message: 'Hello Jhipster info' });
expect(service.addAlert({ type: 'success', message: 'Hello JHipster success' })).toEqual(
expect.objectContaining({
type: 'success',
message: 'Hello JHipster success',
id: 1,
} as Alert),
);

expect(service.get().length).toBe(2);
expect(service.get()[1]).toEqual(
expect.objectContaining({
type: 'success',
message: 'Hello JHipster success',
id: 1,
} as Alert),
);
}));

it('should close an alert correctly', inject([AlertService], (service: AlertService) => {
const alert0 = service.addAlert({ type: 'info', message: 'Hello Jhipster info' });
const alert1 = service.addAlert({ type: 'info', message: 'Hello Jhipster info 2' });
const alert2 = service.addAlert({ type: 'success', message: 'Hello JHipster success' });
expect(alert2).toEqual(
expect.objectContaining({
type: 'success',
message: 'Hello JHipster success',
id: 2,
} as Alert),
);

expect(service.get().length).toBe(3);
alert1.close?.(service.get());
expect(service.get().length).toBe(2);
expect(service.get()[1]).not.toEqual(
expect.objectContaining({
type: 'info',
message: 'Hello Jhipster info 2',
id: 1,
} as Alert),
);
alert2.close?.(service.get());
expect(service.get().length).toBe(1);
expect(service.get()[0]).not.toEqual(
expect.objectContaining({
type: 'success',
message: 'Hello JHipster success',
id: 2,
} as Alert),
);
alert0.close?.(service.get());
expect(service.get().length).toBe(0);
}));

it('should close an alert on timeout correctly', inject([AlertService], (service: AlertService) => {
service.addAlert({ type: 'info', message: 'Hello Jhipster info' });

expect(service.get().length).toBe(1);

jest.advanceTimersByTime(6000);

expect(service.get().length).toBe(0);
}));

it('should clear alerts', inject([AlertService], (service: AlertService) => {
service.addAlert({ type: 'info', message: 'Hello Jhipster info' });
service.addAlert({ type: 'danger', message: 'Hello Jhipster info' });
service.addAlert({ type: 'success', message: 'Hello Jhipster info' });
expect(service.get().length).toBe(3);
service.clear();
expect(service.get().length).toBe(0);
}));

it('should produce a scoped alert', inject([AlertService], (service: AlertService) => {
expect(
service.addAlert(
{
type: 'success',
message: 'Hello Jhipster',
timeout: 3000,
toast: true,
position: 'top left',
},
[],
),
).toEqual(
expect.objectContaining({
type: 'success',
message: 'Hello Jhipster',
id: 0,
timeout: 3000,
toast: true,
position: 'top left',
} as Alert),
);

expect(service.get().length).toBe(0);
}));

it('should produce a success message', inject([AlertService], (service: AlertService) => {
expect(service.addAlert({ type: 'success', message: 'Hello Jhipster' })).toEqual(
expect.objectContaining({
type: 'success',
message: 'Hello Jhipster',
} as Alert),
);
}));

it('should produce a success message with custom position', inject([AlertService], (service: AlertService) => {
expect(service.addAlert({ type: 'success', message: 'Hello Jhipster', position: 'bottom left' })).toEqual(
expect.objectContaining({
type: 'success',
message: 'Hello Jhipster',
position: 'bottom left',
} as Alert),
);
}));

it('should produce a error message', inject([AlertService], (service: AlertService) => {
expect(service.addAlert({ type: 'danger', message: 'Hello Jhipster' })).toEqual(
expect.objectContaining({
type: 'danger',
message: 'Hello Jhipster',
} as Alert),
);
}));

it('should produce a warning message', inject([AlertService], (service: AlertService) => {
expect(service.addAlert({ type: 'warning', message: 'Hello Jhipster' })).toEqual(
expect.objectContaining({
type: 'warning',
message: 'Hello Jhipster',
} as Alert),
);
}));

it('should produce a info message', inject([AlertService], (service: AlertService) => {
expect(service.addAlert({ type: 'info', message: 'Hello Jhipster' })).toEqual(
expect.objectContaining({
type: 'info',
message: 'Hello Jhipster',
} as Alert),
);
}));

it('should produce a info message with translated message if key exists', inject(
[AlertService, TranslateService],
(service: AlertService, translateService: TranslateService) => {
translateService.setTranslation('en', {
'hello.jhipster': 'Translated message',
});
expect(service.addAlert({ type: 'info', message: 'Hello Jhipster', translationKey: 'hello.jhipster' })).toEqual(
expect.objectContaining({
type: 'info',
message: 'Translated message',
} as Alert),
);
},
));

it('should produce a info message with provided message if key does not exists', inject(
[AlertService, TranslateService],
(service: AlertService) => {
expect(service.addAlert({ type: 'info', message: 'Hello Jhipster', translationKey: 'hello.jhipster' })).toEqual(
expect.objectContaining({
type: 'info',
message: 'Hello Jhipster',
} as Alert),
);
},
));

it('should produce a info message with provided key if translation key does not exist in translations and message is not provided', inject(
[AlertService, TranslateService],
(service: AlertService) => {
expect(service.addAlert({ type: 'info', translationKey: 'hello.jhipster' })).toEqual(
expect.objectContaining({
type: 'info',
message: 'hello.jhipster',
} as Alert),
);
},
));
});
});

+ 89
- 0
src/app/core/util/alert.service.ts Ver arquivo

@@ -0,0 +1,89 @@
import { Injectable, SecurityContext, inject } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import {translationNotFoundMessage} from '../../config/translation.config';


export type AlertType = 'success' | 'danger' | 'warning' | 'info';

export interface Alert {
id: number;
type: AlertType;
message?: string;
translationKey?: string;
translationParams?: Record<string, unknown>;
timeout?: number;
toast?: boolean;
position?: string;
close?: (alerts: Alert[]) => void;
}

@Injectable({
providedIn: 'root',
})
export class AlertService {
timeout = 5000;
toast = false;
position = 'top right';

// unique id for each alert. Starts from 0.
private alertId = 0;
private alerts: Alert[] = [];

private readonly sanitizer = inject(DomSanitizer);
private readonly translateService = inject(TranslateService);

clear(): void {
this.alerts = [];
}

get(): Alert[] {
return this.alerts;
}

/**
* Adds alert to alerts array and returns added alert.
* @param alertToAdd Alert to add. If `timeout`, `toast` or `position` is missing then applying default value.
* If `translateKey` is available then it's translation else `message` is used for showing.
* @param extAlerts If missing then adding `alert` to `AlertService` internal array and alerts can be retrieved by `get()`.
* Else adding `alert` to `extAlerts`.
* @returns Added alert
*/
addAlert(alertToAdd: Omit<Alert, 'id'>, extAlerts?: Alert[]): Alert {
const alert: Alert = { ...alertToAdd, id: this.alertId++ };

if (alert.translationKey) {
const translatedMessage = this.translateService.instant(alert.translationKey, alert.translationParams);
// if translation key exists
if (translatedMessage !== `${translationNotFoundMessage}[${alert.translationKey}]`) {
alert.message = translatedMessage;
}
alert.message ??= alert.translationKey;
}

alert.message = this.sanitizer.sanitize(SecurityContext.HTML, alert.message ?? '') ?? '';
alert.timeout = alert.timeout ?? this.timeout;
alert.toast = alert.toast ?? this.toast;
alert.position = alert.position ?? this.position;
alert.close = (alertsArray: Alert[]) => this.closeAlert(alert.id, alertsArray);

(extAlerts ?? this.alerts).push(alert);

if (alert.timeout > 0) {
setTimeout(() => {
this.closeAlert(alert.id, extAlerts ?? this.alerts);
}, alert.timeout);
}

return alert;
}

private closeAlert(alertId: number, extAlerts?: Alert[]): void {
const alerts = extAlerts ?? this.alerts;
const alertIndex = alerts.map(alert => alert.id).indexOf(alertId);
// if found alert then remove
if (alertIndex >= 0) {
alerts.splice(alertIndex, 1);
}
}
}

+ 33
- 0
src/app/core/util/data-util.service.spec.ts Ver arquivo

@@ -0,0 +1,33 @@
import { TestBed } from '@angular/core/testing';

import { DataUtils } from './data-util.service';

describe('Data Utils Service Test', () => {
let service: DataUtils;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [DataUtils],
});
service = TestBed.inject(DataUtils);
});

describe('byteSize', () => {
it('should return the bytesize of the text', () => {
expect(service.byteSize('Hello JHipster')).toBe(`10.5 bytes`);
});
});

describe('openFile', () => {
it('should open the file in the new window', () => {
const newWindow = { ...window };
window.open = jest.fn(() => newWindow);
window.URL.createObjectURL = jest.fn();
// 'JHipster' in base64 is 'SkhpcHN0ZXI='
const data = 'SkhpcHN0ZXI=';
const contentType = 'text/plain';
service.openFile(data, contentType);
expect(window.open).toHaveBeenCalledTimes(1);
});
});
});

+ 131
- 0
src/app/core/util/data-util.service.ts Ver arquivo

@@ -0,0 +1,131 @@
import { Injectable } from '@angular/core';
import { Buffer } from 'buffer';

import { FormGroup } from '@angular/forms';
import { Observable, Observer } from 'rxjs';

export type FileLoadErrorType = 'not.image' | 'could.not.extract';

export interface FileLoadError {
message: string;
key: FileLoadErrorType;
params?: any;
}

/**
* A utility service for data.
*/
@Injectable({
providedIn: 'root',
})
export class DataUtils {
/**
* Method to find the byte size of the string provides
*/
byteSize(base64String: string): string {
return this.formatAsBytes(this.size(base64String));
}

/**
* Method to open file
*/
openFile(data: string, contentType: string | null | undefined): void {
contentType = contentType ?? '';

const byteCharacters = Buffer.from(data, 'base64').toString('binary');
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], {
type: contentType,
});
const fileURL = window.URL.createObjectURL(blob);
const win = window.open(fileURL);
win!.onload = function () {
URL.revokeObjectURL(fileURL);
};
}

/**
* Sets the base 64 data & file type of the 1st file on the event (event.target.files[0]) in the passed entity object
* and returns an observable.
*
* @param event the object containing the file (at event.target.files[0])
* @param editForm the form group where the input field is located
* @param field the field name to set the file's 'base 64 data' on
* @param isImage boolean representing if the file represented by the event is an image
* @returns an observable that loads file to form field and completes if successful
* or returns error as FileLoadError on failure
*/
loadFileToForm(event: Event, editForm: FormGroup, field: string, isImage: boolean): Observable<void> {
return new Observable((observer: Observer<void>) => {
const eventTarget: HTMLInputElement | null = event.target as HTMLInputElement | null;
if (eventTarget?.files?.[0]) {
const file: File = eventTarget.files[0];
if (isImage && !file.type.startsWith('image/')) {
const error: FileLoadError = {
message: `File was expected to be an image but was found to be '${file.type}'`,
key: 'not.image',
params: { fileType: file.type },
};
observer.error(error);
} else {
const fieldContentType = `${field}ContentType`;
this.toBase64(file, (base64Data: string) => {
editForm.patchValue({
[field]: base64Data,
[fieldContentType]: file.type,
});
observer.next();
observer.complete();
});
}
} else {
const error: FileLoadError = {
message: 'Could not extract file',
key: 'could.not.extract',
params: { event },
};
observer.error(error);
}
});
}

/**
* Method to convert the file to base64
*/
private toBase64(file: File, callback: (base64Data: string) => void): void {
const fileReader: FileReader = new FileReader();
fileReader.onload = (e: ProgressEvent<FileReader>) => {
if (typeof e.target?.result === 'string') {
const base64Data: string = e.target.result.substring(e.target.result.indexOf('base64,') + 'base64,'.length);
callback(base64Data);
}
};
fileReader.readAsDataURL(file);
}

private endsWith(suffix: string, str: string): boolean {
return str.includes(suffix, str.length - suffix.length);
}

private paddingSize(value: string): number {
if (this.endsWith('==', value)) {
return 2;
}
if (this.endsWith('=', value)) {
return 1;
}
return 0;
}

private size(value: string): number {
return (value.length / 4) * 3 - this.paddingSize(value);
}

private formatAsBytes(size: number): string {
return `${size.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')} bytes`; // NOSONAR
}
}

+ 84
- 0
src/app/core/util/event-manager.service.spec.ts Ver arquivo

@@ -0,0 +1,84 @@
import { TestBed, inject } from '@angular/core/testing';

import { EventManager, EventWithContent } from './event-manager.service';

describe('Event Manager tests', () => {
describe('EventWithContent', () => {
it('should create correctly EventWithContent', () => {
// WHEN
const eventWithContent = new EventWithContent('name', 'content');

// THEN
expect(eventWithContent).toEqual({ name: 'name', content: 'content' });
});
});

describe('EventManager', () => {
let receivedEvent: EventWithContent<unknown> | string | null;

beforeEach(() => {
TestBed.configureTestingModule({
providers: [EventManager],
});
receivedEvent = null;
});

it('should not fail when nosubscriber and broadcasting', inject([EventManager], (eventManager: EventManager) => {
expect(eventManager.observer).toBeUndefined();
eventManager.broadcast({ name: 'modifier', content: 'modified something' });
}));

it('should create an observable and callback when broadcasted EventWithContent', inject(
[EventManager],
(eventManager: EventManager) => {
// GIVEN
eventManager.subscribe('modifier', (event: EventWithContent<unknown> | string) => (receivedEvent = event));

// WHEN
eventManager.broadcast({ name: 'unrelatedModifier', content: 'unrelated modification' });
// THEN
expect(receivedEvent).toBeNull();

// WHEN
eventManager.broadcast({ name: 'modifier', content: 'modified something' });
// THEN
expect(receivedEvent).toEqual({ name: 'modifier', content: 'modified something' });
},
));

it('should create an observable and callback when broadcasted string', inject([EventManager], (eventManager: EventManager) => {
// GIVEN
eventManager.subscribe('modifier', (event: EventWithContent<unknown> | string) => (receivedEvent = event));

// WHEN
eventManager.broadcast('unrelatedModifier');
// THEN
expect(receivedEvent).toBeNull();

// WHEN
eventManager.broadcast('modifier');
// THEN
expect(receivedEvent).toEqual('modifier');
}));

it('should subscribe to multiple events', inject([EventManager], (eventManager: EventManager) => {
// GIVEN
eventManager.subscribe(['modifier', 'modifier2'], (event: EventWithContent<unknown> | string) => (receivedEvent = event));

// WHEN
eventManager.broadcast('unrelatedModifier');
// THEN
expect(receivedEvent).toBeNull();

// WHEN
eventManager.broadcast({ name: 'modifier', content: 'modified something' });
// THEN
expect(receivedEvent).toEqual({ name: 'modifier', content: 'modified something' });

// WHEN
eventManager.broadcast('modifier2');
// THEN
expect(receivedEvent).toEqual('modifier2');
}));
});
});

+ 66
- 0
src/app/core/util/event-manager.service.ts Ver arquivo

@@ -0,0 +1,66 @@
import { Injectable } from '@angular/core';
import { Observable, Observer, Subscription } from 'rxjs';
import { filter, share } from 'rxjs/operators';

export class EventWithContent<T> {
constructor(
public name: string,
public content: T,
) {}
}

/**
* A utility class to manage RX events
*/
@Injectable({
providedIn: 'root',
})
export class EventManager {
observable: Observable<EventWithContent<unknown> | string>;
observer?: Observer<EventWithContent<unknown> | string>;

constructor() {
this.observable = new Observable((observer: Observer<EventWithContent<unknown> | string>) => {
this.observer = observer;
}).pipe(share());
}

/**
* Method to broadcast the event to observer
*/
broadcast(event: EventWithContent<unknown> | string): void {
if (this.observer) {
this.observer.next(event);
}
}

/**
* Method to subscribe to an event with callback
* @param eventNames Single event name or array of event names to what subscribe
* @param callback Callback to run when the event occurs
*/
subscribe(eventNames: string | string[], callback: (event: EventWithContent<unknown> | string) => void): Subscription {
if (typeof eventNames === 'string') {
eventNames = [eventNames];
}
return this.observable
.pipe(
filter((event: EventWithContent<unknown> | string) => {
for (const eventName of eventNames) {
if ((typeof event === 'string' && event === eventName) || (typeof event !== 'string' && event.name === eventName)) {
return true;
}
}
return false;
}),
)
.subscribe(callback);
}

/**
* Method to unsubscribe the subscription
*/
destroy(subscriber: Subscription): void {
subscriber.unsubscribe();
}
}

+ 18
- 0
src/app/core/util/operators.spec.ts Ver arquivo

@@ -0,0 +1,18 @@
import { filterNaN, isPresent } from './operators';

describe('Operators Test', () => {
describe('isPresent', () => {
it('should remove null and undefined values', () => {
expect([1, null, undefined].filter(isPresent)).toEqual([1]);
});
});

describe('filterNaN', () => {
it('should return 0 for NaN', () => {
expect(filterNaN(NaN)).toBe(0);
});
it('should return number for a number', () => {
expect(filterNaN(12345)).toBe(12345);
});
});
});

+ 9
- 0
src/app/core/util/operators.ts Ver arquivo

@@ -0,0 +1,9 @@
/*
* Function used to workaround https://github.com/microsoft/TypeScript/issues/16069
* es2019 alternative `const filteredArr = myArr.flatMap((x) => x ? x : []);`
*/
export function isPresent<T>(t: T | undefined | null): t is T {
return t !== undefined && t !== null;
}

export const filterNaN = (input: number): number => (isNaN(input) ? 0 : input);

+ 66
- 0
src/app/core/util/parse-links.service.spec.ts Ver arquivo

@@ -0,0 +1,66 @@
import { TestBed, inject } from '@angular/core/testing';

import { ParseLinks } from './parse-links.service';

describe('Parse links service test', () => {
describe('parse', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ParseLinks],
});
});

it('should throw an error when passed an empty string', inject([ParseLinks], (service: ParseLinks) => {
expect(function () {
service.parse('');
}).toThrow(new Error('input must not be of zero length'));
}));

it('should throw an error when passed without comma', inject([ParseLinks], (service: ParseLinks) => {
expect(function () {
service.parse('test');
}).toThrow(new Error('section could not be split on ";"'));
}));

it('should throw an error when passed without semicolon', inject([ParseLinks], (service: ParseLinks) => {
expect(function () {
service.parse('test,test2');
}).toThrow(new Error('section could not be split on ";"'));
}));

it('should return links when headers are passed', inject([ParseLinks], (service: ParseLinks) => {
const links = { last: 0, first: 0 };
expect(service.parse(' </api/audits?page=0&size=20>; rel="last",</api/audits?page=0&size=20>; rel="first"')).toEqual(links);
}));
});
describe('parseAll', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [ParseLinks],
});
});

it('should throw an error when passed an empty string', inject([ParseLinks], (service: ParseLinks) => {
expect(function () {
service.parseAll('');
}).toThrow(new Error('input must not be of zero length'));
}));

it('should throw an error when passed without comma', inject([ParseLinks], (service: ParseLinks) => {
expect(function () {
service.parseAll('test');
}).toThrow(new Error('section could not be split on ";"'));
}));

it('should throw an error when passed without semicolon', inject([ParseLinks], (service: ParseLinks) => {
expect(function () {
service.parseAll('test,test2');
}).toThrow(new Error('section could not be split on ";"'));
}));

it('should return links when headers are passed', inject([ParseLinks], (service: ParseLinks) => {
const links = { last: { page: '0', size: '20' }, first: { page: '0', size: '20' } };
expect(service.parseAll(' </api/audits?page=0&size=20>; rel="last",</api/audits?page=0&size=20>; rel="first"')).toEqual(links);
}));
});
});

+ 59
- 0
src/app/core/util/parse-links.service.ts Ver arquivo

@@ -0,0 +1,59 @@
import { Injectable } from '@angular/core';

/**
* A utility service for link parsing.
*/
@Injectable({
providedIn: 'root',
})
export class ParseLinks {
/**
* Method to parse the links
*/
parseAll(header: string): Record<string, Record<string, string | undefined> | undefined> {
if (header.length === 0) {
throw new Error('input must not be of zero length');
}

// Split parts by comma
const parts: string[] = header.split(',');

// Parse each part into a named link
return Object.fromEntries(
parts.map(p => {
const section: string[] = p.split(';');

if (section.length !== 2) {
throw new Error('section could not be split on ";"');
}

const url: string = section[0].replace(/<(.*)>/, '$1').trim(); // NOSONAR
const queryString: Record<string, string> = {};

url.replace(/([^?=&]+)(=([^&]*))?/g, (_$0: string, $1: string | undefined, _$2: string | undefined, $3: string | undefined) => {
if ($1 !== undefined && $3 !== undefined) {
queryString[$1] = decodeURIComponent($3);
}
return $3 ?? '';
});

const name: string = section[1].replace(/rel="(.*)"/, '$1').trim();
return [name, queryString];
}),
);
}

/**
* Method to parse the links
*/
parse(header: string): Record<string, number> {
const sections = this.parseAll(header);
const links: Record<string, number> = {};
for (const [name, queryParams] of Object.entries(sections)) {
if (queryParams?.["page"] !== undefined) {
links[name] = parseInt(queryParams["page"], 10);
}
}
return links;
}
}

+ 291
- 0
src/app/data/mock-data.ts Ver arquivo

@@ -0,0 +1,291 @@
import { Agent, Conversation, Project } from '../models/data.model';

export const MOCK_AGENTS: Agent[] = [
{
id: 'default',
name: 'Par défaut',
description: 'Discutez avec un agent IA ayant une base de donées important dans votre domaine',
icon: 'edit',
},
{
id: 'audit',
name: 'Agent Audit',
description: 'Un agent IA entrainé dans la creation de bilan comtable sur la base de vos comptes-rendus ',
icon: 'align-left',
},
{
id: 'audit-docs',
name: 'L\'agent Audit',
description: 'Un agent IA spécialisé dans l\'étude de document et dans la réalisation d\'audit financier ',
icon: 'file-alt',
},
{
id: 'email',
name: 'l\'agent mail spécialisés ',
description: 'Un agent IA spécialisé dans la rédaction de mail professionel avec une approche humaine et experte ',
icon: 'envelope',
},
];

export const MOCK_CONVERSATIONS: Conversation[] = [
{
id: 'conv1',
title: 'Analyse détaillée des bilans comptables Q3 2024',
messages: [
{
id: 'msg1',
content: 'Bonjour, j\'aurais besoin d\'une analyse complète de nos bilans du Q3. Je m\'inquiète notamment de la variance des coûts opérationnels qui semble avoir augmenté de manière significative. Pouvez-vous m\'aider à comprendre d\'où vient cette augmentation ?',
sender: 'user',
timestamp: new Date('2024-11-20T10:30:00'),
},
{
id: 'msg2',
content: 'Bonjour ! Bien sûr, je vais analyser en détail vos bilans du Q3 2024. J\'ai déjà effectué une première lecture et j\'identifie plusieurs points importants. Laissez-moi vous présenter mes observations de manière structurée.',
sender: 'agent',
timestamp: new Date('2024-11-20T10:30:45'),
},
{
id: 'msg3',
content: 'Parfait, je vous écoute. Qu\'avez-vous découvert ?',
sender: 'user',
timestamp: new Date('2024-11-20T10:31:10'),
},
{
id: 'msg4',
content: 'Voici mon analyse détaillée :\n\n1. **Variance des coûts opérationnels** : +12,5% par rapport au Q2\n - Augmentation des frais de déplacement : +45% (20 000€)\n - Hausse des coûts de sous-traitance : +28% (35 000€)\n - Charges de personnel : +8% (stable, inflation normale)\n\n2. **Chiffre d\'affaires** : 450 000€ (+6% vs Q2)\n - Marge brute : 62% (en baisse de 3 points)\n - Cette baisse de marge explique en partie vos inquiétudes\n\n3. **Points positifs** :\n - Trésorerie en hausse : +15%\n - Nouveaux contrats signés : +3 clients stratégiques\n - Délai de paiement clients : amélioration de 5 jours',
sender: 'agent',
timestamp: new Date('2024-11-20T10:32:30'),
},
{
id: 'msg5',
content: 'Merci pour cette analyse. La hausse des frais de déplacement est effectivement problématique. Nous avons eu plusieurs déplacements internationaux non prévus. Que recommandez-vous pour améliorer notre rentabilité au Q4 ?',
sender: 'user',
timestamp: new Date('2024-11-20T10:34:00'),
},
{
id: 'msg6',
content: 'Excellent question ! Voici mes recommandations stratégiques pour le Q4 :\n\n**Actions immédiates (1-2 semaines) :**\n1. Mettre en place une politique de validation des déplacements internationaux\n2. Négocier un accord-cadre avec un prestataire de voyages d\'affaires (-15 à 20% sur les coûts)\n3. Privilégier les visioconférences pour les réunions non critiques\n\n**Actions à moyen terme (Q4) :**\n1. Renégocier 2-3 contrats de sous-traitance (potentiel d\'économie : 15 000€)\n2. Optimiser le mix produits/services pour améliorer la marge (focus sur les offres à 70%+ de marge)\n3. Mettre en place un tableau de bord hebdomadaire des coûts\n\n**Objectifs Q4 :**\n- Réduire les coûts opérationnels de 8%\n- Maintenir le CA à 460 000€ minimum\n- Retrouver une marge brute de 65%\n\nCela vous permettrait d\'économiser environ 40 000€ sur le Q4. Souhaitez-vous que je vous prépare un plan d\'action détaillé ?',
sender: 'agent',
timestamp: new Date('2024-11-20T10:36:15'),
},
{
id: 'msg7',
content: 'Oui, ce serait parfait ! Pouvez-vous aussi inclure une projection des résultats si nous appliquons ces recommandations ?',
sender: 'user',
timestamp: new Date('2024-11-20T10:37:00'),
},
{
id: 'msg8',
content: 'Absolument ! Je vais préparer un document complet avec :\n\n1. Un plan d\'action détaillé sur 90 jours\n2. Trois scénarios de projection :\n - Scénario conservateur (50% des actions mises en œuvre)\n - Scénario réaliste (75% des actions)\n - Scénario optimiste (100% des actions)\n\n3. Un tableau de bord avec les KPIs à suivre hebdomadairement\n4. Un calendrier de points de contrôle mensuels\n\nJe vous envoie ce dossier d\'ici 2 heures. En parallèle, je vous suggère de planifier une réunion avec votre équipe finance pour valider ces orientations. Avez-vous d\'autres questions sur l\'analyse du Q3 ?',
sender: 'agent',
timestamp: new Date('2024-11-20T10:38:30'),
},
{
id: 'msg9',
content: 'Non, c\'est parfait. Merci pour cette analyse très complète et ces recommandations concrètes. J\'attends votre document avec impatience !',
sender: 'user',
timestamp: new Date('2024-11-20T10:39:00'),
},
{
id: 'msg10',
content: 'Avec plaisir ! Je me mets au travail immédiatement. N\'hésitez pas à me recontacter si vous avez besoin de précisions. Bonne journée ! 📊',
sender: 'agent',
timestamp: new Date('2024-11-20T10:39:20'),
}
],
lastUpdate: new Date('2024-11-20T10:39:20'),
agentId: 'audit'
},
{
id: 'conv2',
title: 'Demande de congé pour le mois de Janvier',
messages: [],
lastUpdate: new Date('2024-11-23T15:00:30'),
agentId: 'rh'
},
{
id: 'conv3',
title: 'Audit des documents fiscaux pour l\'année N-1',
messages: [],
lastUpdate: new Date('2024-11-18T09:15:00'),
agentId: 'audit-docs'
},
{
id: 'conv4',
title: 'Problème de connexion VPN sur mobile',
messages: [],
lastUpdate: new Date('2024-11-24T10:45:35'),
agentId: 'it'
},
{
id: 'conv5',
title: 'Rédaction d\'une clause de non-concurrence',
messages: [],
lastUpdate: new Date('2024-11-22T14:01:15'),
agentId: 'legal'
},
{
id: 'conv6',
title: 'Demande d\'information générale',
messages: [],
lastUpdate: new Date('2024-11-17T16:45:00'),
agentId: 'default'
},
{
id: 'conv7',
title: 'Préparation du rapport annuel des ventes',
messages: [],
lastUpdate: new Date('2024-11-15T11:00:00'),
agentId: 'audit'
}
];

export const MOCK_PROJECTS: Project[] = [
{
id: '1',
name: 'Projet Finance 2024',
conversations: [
{
id: 'proj1-conv1',
title: 'Budget prévisionnel 2025',
messages: [],
lastUpdate: new Date('2024-11-22T09:00:00'),
agentId: 'audit'
},
{
id: 'proj1-conv2',
title: 'Analyse des dépenses Q1-Q3',
messages: [],
lastUpdate: new Date('2024-11-21T14:30:00'),
agentId: 'audit'
},
{
id: 'proj1-conv3',
title: 'Optimisation des coûts',
messages: [],
lastUpdate: new Date('2024-11-20T11:15:00'),
agentId: 'default'
}
],
expanded: false
},
{
id: '2',
name: 'Rapports Comptables',
conversations: [
{
id: 'proj2-conv1',
title: 'Rapport mensuel Novembre',
messages: [],
lastUpdate: new Date('2024-11-24T16:00:00'),
agentId: 'audit'
},
{
id: 'proj2-conv2',
title: 'Bilan trimestriel Q4 (en cours)',
messages: [],
lastUpdate: new Date('2024-11-23T10:45:00'),
agentId: 'audit'
},
{
id: 'proj2-conv3',
title: 'Validation des écritures 2024',
messages: [],
lastUpdate: new Date('2024-11-20T11:00:00'),
agentId: 'audit-docs'
}
],
expanded: false
},
{
id: '3',
name: 'Audits Fiscaux Q4',
conversations: [
{
id: 'proj3-conv1',
title: 'Audit fiscal Décembre',
messages: [],
lastUpdate: new Date('2024-11-23T13:20:00'),
agentId: 'audit-docs'
},
{
id: 'proj3-conv2',
title: 'Vérification des comptes fournisseurs',
messages: [],
lastUpdate: new Date('2024-11-22T15:30:00'),
agentId: 'audit-docs'
},
{
id: 'proj3-conv3',
title: 'Contrôle des procédures internes',
messages: [],
lastUpdate: new Date('2024-11-21T09:00:00'),
agentId: 'audit'
}
],
expanded: false
},
{
id: '4',
name: 'Ressources Humaines',
conversations: [
{
id: 'proj4-conv1',
title: 'Grille salariale 2025',
messages: [],
lastUpdate: new Date('2024-11-24T11:00:00'),
agentId: 'rh'
},
{
id: 'proj4-conv2',
title: 'Politique de télétravail',
messages: [],
lastUpdate: new Date('2024-11-21T14:15:00'),
agentId: 'rh'
}
],
expanded: false
},
{
id: '5',
name: 'Assistance Informatique',
conversations: [
{
id: 'proj5-conv1',
title: 'Configuration nouvelle imprimante réseau',
messages: [],
lastUpdate: new Date('2024-11-23T10:00:00'),
agentId: 'it'
},
{
id: 'proj5-conv2',
title: 'Mise à jour des systèmes d\'exploitation',
messages: [],
lastUpdate: new Date('2024-11-20T17:00:00'),
agentId: 'it'
}
],
expanded: false
},
{
id: '6',
name: 'Documents Légaux',
conversations: [
{
id: 'proj6-conv1',
title: 'Vérification des contrats fournisseurs',
messages: [],
lastUpdate: new Date('2024-11-24T09:30:00'),
agentId: 'legal'
},
{
id: 'proj6-conv2',
title: 'Accord de confidentialité',
messages: [],
lastUpdate: new Date('2024-11-21T12:00:00'),
agentId: 'legal'
}
],
expanded: false
}
];

+ 28
- 0
src/app/models/data.model.ts Ver arquivo

@@ -0,0 +1,28 @@
export interface Agent {
id: string;
name: string;
description?: string;
icon: string;
}

export interface Message {
id: string;
content: string;
sender: 'user' | 'agent';
timestamp: Date;
}

export interface Conversation {
id: string;
title: string;
messages: Message[];
lastUpdate: Date;
agentId: string;
}

export interface Project {
id: string;
name: string;
conversations: Conversation[];
expanded?: boolean;
}

+ 7
- 0
src/app/pages/login/login.model.ts Ver arquivo

@@ -0,0 +1,7 @@
export class Login {
constructor(
public username: string,
public password: string,
public rememberMe: boolean,
) {}
}

+ 23
- 0
src/app/pages/login/login.service.ts Ver arquivo

@@ -0,0 +1,23 @@
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { mergeMap } from 'rxjs/operators';
import {AccountService} from '../../core/auth/account.service';
import {AuthServerProvider} from '../../core/auth/auth-jwt.service';
import {Account} from '../../core/auth/account.model';
import {Login} from './login.model';



@Injectable({ providedIn: 'root' })
export class LoginService {
private readonly accountService = inject(AccountService);
private readonly authServerProvider = inject(AuthServerProvider);

login(credentials: Login): Observable<Account | null> {
return this.authServerProvider.login(credentials).pipe(mergeMap(() => this.accountService.identity(true)));
}

logout(): void {
this.authServerProvider.logout().subscribe({ complete: () => this.accountService.authenticate(null) });
}
}

+ 125
- 0
src/app/services/data.service.ts Ver arquivo

@@ -0,0 +1,125 @@
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
import { Agent, Conversation, Message, Project } from '../models/data.model';
import {MOCK_AGENTS, MOCK_CONVERSATIONS, MOCK_PROJECTS} from '../data/mock-data';

@Injectable({
providedIn: 'root'
})
export class DataService {
private agents$ = new BehaviorSubject<Agent[]>(MOCK_AGENTS);
private conversations$ = new BehaviorSubject<Conversation[]>(MOCK_CONVERSATIONS);
private currentConversation$ = new BehaviorSubject<Conversation | null>(null);
private projects$ = new BehaviorSubject<Project[]>(MOCK_PROJECTS);

getAgents(): Observable<Agent[]> {
return this.agents$.asObservable();
}

getConversations(): Observable<Conversation[]> {
return this.conversations$.asObservable();
}

getCurrentConversation(): Observable<Conversation | null> {
return this.currentConversation$.asObservable();
}

getProjects(): Observable<Project[]> {
return this.projects$.asObservable();
}

createNewConversation(agentId: string): void {
const conversation: Conversation = {
id: this.generateId(),
title: 'Nouvelle conversation...',
messages: [],
lastUpdate: new Date(),
agentId
};

const conversations = this.conversations$.value;
conversations.unshift(conversation);
this.conversations$.next(conversations);
this.currentConversation$.next(conversation);
}

addMessage(message: Omit<Message, 'id' | 'timestamp'>): void {
const current = this.currentConversation$.value;
if (!current) return;

const newMessage: Message = {
...message,
id: this.generateId(),
timestamp: new Date()
};

current.messages.push(newMessage);
current.lastUpdate = new Date();

// Update title with first user message
if (current.messages.length === 1 && message.sender === 'user') {
current.title = message.content.substring(0, 50) + '...';
}

this.currentConversation$.next({ ...current });
this.updateConversationsList(current);
}

selectConversation(conversationId: string): void {
const conversation = this.conversations$.value.find(c => c.id === conversationId);
if (conversation) {
this.currentConversation$.next(conversation);
return;
}

const projects = this.projects$.value;
for (const project of projects) {
const projectConv = project.conversations?.find(c => c.id === conversationId);
if (projectConv) {
this.currentConversation$.next(projectConv);
return;
}
}
}

private updateConversationsList(conversation: Conversation): void {
const conversations = this.conversations$.value;
const index = conversations.findIndex(c => c.id === conversation.id);
if (index !== -1) {
conversations[index] = conversation;
this.conversations$.next([...conversations]);
}
}
resetCurrentConversation(): void {
this.currentConversation$.next(null);
}
private generateId(): string {
return Math.random().toString(36).substr(2, 9);
}

simulateAgentResponse(agentId: string, userMessage: string): void {
setTimeout(() => {
const agent = this.agents$.value.find(a => a.id === agentId);
const responses = [
`Je comprends votre question concernant "${userMessage.substring(0, 30)}...". Laissez-moi vous aider avec cela.`,
`C'est une excellente question. Voici ce que je peux vous dire à ce sujet...`,
`Merci pour votre message. En tant qu'agent ${agent?.name}, je vais analyser votre demande.`,
`Je vais traiter votre demande immédiatement. Voici ma réponse...`
];

this.addMessage({
content: responses[Math.floor(Math.random() * responses.length)],
sender: 'agent',
});
}, 1000 + Math.random() * 1000);
}

toggleProject(projectId: string): void {
const projects = this.projects$.value;
const project = projects.find(p => p.id === projectId);
if (project) {
project.expanded = !project.expanded;
this.projects$.next([...projects]);
}
}
}

+ 11
- 0
src/app/shared/alert/alert-error.component.html Ver arquivo

@@ -0,0 +1,11 @@
<div class="alerts" role="alert">
@for (alert of alerts(); track $index) {
<div [ngClass]="setClasses(alert)">
@if (alert.message) {
<ngb-alert [type]="alert.type" (closed)="close(alert)">
<pre [innerHTML]="alert.message"></pre>
</ngb-alert>
}
</div>
}
</div>

+ 159
- 0
src/app/shared/alert/alert-error.component.spec.ts Ver arquivo

@@ -0,0 +1,159 @@
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { TranslateModule } from '@ngx-translate/core';

import { EventManager } from 'app/core/util/event-manager.service';
import { Alert, AlertService } from 'app/core/util/alert.service';

import { AlertErrorComponent } from './alert-error.component';

describe('Alert Error Component', () => {
let comp: AlertErrorComponent;
let fixture: ComponentFixture<AlertErrorComponent>;
let eventManager: EventManager;
let alertService: AlertService;

beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), AlertErrorComponent],
providers: [EventManager, AlertService],
})
.overrideTemplate(AlertErrorComponent, '')
.compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(AlertErrorComponent);
comp = fixture.componentInstance;
eventManager = TestBed.inject(EventManager);
alertService = TestBed.inject(AlertService);
alertService.addAlert = (alert: Alert, alerts?: Alert[]) => {
if (alerts) {
alerts.push(alert);
}
return alert;
};
});

describe('Error Handling', () => {
it('should display an alert on status 0', () => {
// GIVEN
eventManager.broadcast({ name: 'villeveyracApp.httpError', content: { status: 0 } });
// THEN
expect(comp.alerts().length).toBe(1);
expect(comp.alerts()[0].translationKey).toBe('error.server.not.reachable');
});

it('should display an alert on status 404', () => {
// GIVEN
eventManager.broadcast({ name: 'villeveyracApp.httpError', content: { status: 404 } });
// THEN
expect(comp.alerts().length).toBe(1);
expect(comp.alerts()[0].translationKey).toBe('error.url.not.found');
});

it('should display an alert on generic error', () => {
// GIVEN
eventManager.broadcast({ name: 'villeveyracApp.httpError', content: { error: { message: 'Error Message' } } });
eventManager.broadcast({ name: 'villeveyracApp.httpError', content: { error: 'Second Error Message' } });
// THEN
expect(comp.alerts().length).toBe(2);
expect(comp.alerts()[0].translationKey).toBe('Error Message');
expect(comp.alerts()[1].translationKey).toBe('Second Error Message');
});

it('should display an alert on status 400 for generic error', () => {
// GIVEN
const response = new HttpErrorResponse({
url: 'http://localhost:8080/api/foos',
headers: new HttpHeaders(),
status: 400,
statusText: 'Bad Request',
error: {
type: 'https://www.jhipster.tech/problem/problem-with-message',
title: 'Bad Request',
status: 400,
path: '/api/foos',
message: 'error.validation',
},
});
eventManager.broadcast({ name: 'villeveyracApp.httpError', content: response });
// THEN
expect(comp.alerts().length).toBe(1);
expect(comp.alerts()[0].translationKey).toBe('error.validation');
});

it('should display an alert on status 400 for generic error without message', () => {
// GIVEN
const response = new HttpErrorResponse({
url: 'http://localhost:8080/api/foos',
headers: new HttpHeaders(),
status: 400,
error: 'Bad Request',
});
eventManager.broadcast({ name: 'villeveyracApp.httpError', content: response });
// THEN
expect(comp.alerts().length).toBe(1);
expect(comp.alerts()[0].translationKey).toBe('Bad Request');
});

it('should display an alert on status 400 for invalid parameters', () => {
// GIVEN
const response = new HttpErrorResponse({
url: 'http://localhost:8080/api/foos',
headers: new HttpHeaders(),
status: 400,
statusText: 'Bad Request',
error: {
type: 'https://www.jhipster.tech/problem/problem-with-message',
title: 'Method argument not valid',
status: 400,
path: '/api/foos',
message: 'error.validation',
fieldErrors: [{ objectName: 'foo', field: 'minField', message: 'Min' }],
},
});
eventManager.broadcast({ name: 'villeveyracApp.httpError', content: response });
// THEN
expect(comp.alerts().length).toBe(1);
expect(comp.alerts()[0].translationKey).toBe('error.Size');
});

it('should display an alert on status 400 for error headers', () => {
// GIVEN
const response = new HttpErrorResponse({
url: 'http://localhost:8080/api/foos',
headers: new HttpHeaders().append('app-error', 'Error Message').append('app-params', 'foo'),
status: 400,
statusText: 'Bad Request',
error: {
status: 400,
message: 'error.validation',
},
});
eventManager.broadcast({ name: 'villeveyracApp.httpError', content: response });
// THEN
expect(comp.alerts().length).toBe(1);
expect(comp.alerts()[0].translationKey).toBe('Error Message');
});

it('should display an alert on status 500 with detail', () => {
// GIVEN
const response = new HttpErrorResponse({
url: 'http://localhost:8080/api/foos',
headers: new HttpHeaders(),
status: 500,
statusText: 'Internal server error',
error: {
status: 500,
message: 'error.http.500',
detail: 'Detailed error message',
},
});
eventManager.broadcast({ name: 'villeveyracApp.httpError', content: response });
// THEN
expect(comp.alerts().length).toBe(1);
expect(comp.alerts()[0].translationKey).toBe('error.http.500');
});
});
});

+ 132
- 0
src/app/shared/alert/alert-error.component.ts Ver arquivo

@@ -0,0 +1,132 @@
import { Component, OnDestroy, inject, signal } from '@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { Subscription } from 'rxjs';
import { CommonModule } from '@angular/common';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { TranslateService } from '@ngx-translate/core';

import { Alert, AlertService } from 'app/core/util/alert.service';
import { EventManager, EventWithContent } from 'app/core/util/event-manager.service';
import { AlertError } from './alert-error.model';

@Component({
selector: 'jhi-alert-error',
templateUrl: './alert-error.component.html',
imports: [CommonModule, NgbModule],
})
export class AlertErrorComponent implements OnDestroy {
alerts = signal<Alert[]>([]);
errorListener: Subscription;
httpErrorListener: Subscription;

private readonly alertService = inject(AlertService);
private readonly eventManager = inject(EventManager);

private readonly translateService = inject(TranslateService);

constructor() {
this.errorListener = this.eventManager.subscribe('villeveyracApp.error', (response: EventWithContent<unknown> | string) => {
const errorResponse = (response as EventWithContent<AlertError>).content;
this.addErrorAlert(errorResponse.message, errorResponse.key, errorResponse.params);
});

this.httpErrorListener = this.eventManager.subscribe('villeveyracApp.httpError', (response: EventWithContent<unknown> | string) => {
this.handleHttpError(response);
});
}

setClasses(alert: Alert): Record<string, boolean> {
const classes = { 'jhi-toast': Boolean(alert.toast) };
if (alert.position) {
return { ...classes, [alert.position]: true };
}
return classes;
}

ngOnDestroy(): void {
this.eventManager.destroy(this.errorListener);
this.eventManager.destroy(this.httpErrorListener);
}

close(alert: Alert): void {
alert.close?.(this.alerts());
}

private addErrorAlert(message?: string, translationKey?: string, translationParams?: Record<string, unknown>): void {
this.alertService.addAlert({ type: 'danger', message, translationKey, translationParams }, this.alerts());
}

private handleHttpError(response: EventWithContent<unknown> | string): void {
const httpErrorResponse = (response as EventWithContent<HttpErrorResponse>).content;
switch (httpErrorResponse.status) {
// connection refused, server not reachable
case 0:
this.addErrorAlert('Server not reachable', 'error.server.not.reachable');
break;

case 400: {
this.handleBadRequest(httpErrorResponse);
break;
}

case 404:
this.addErrorAlert('Not found', 'error.url.not.found');
break;

default:
this.handleDefaultError(httpErrorResponse);
}
}

private handleBadRequest(httpErrorResponse: HttpErrorResponse): void {
const arr = httpErrorResponse.headers.keys();
let errorHeader: string | null = null;
let entityKey: string | null = null;
for (const entry of arr) {
if (entry.toLowerCase().endsWith('app-error')) {
errorHeader = httpErrorResponse.headers.get(entry);
} else if (entry.toLowerCase().endsWith('app-params')) {
entityKey = httpErrorResponse.headers.get(entry);
}
}
if (errorHeader) {
const alertData = entityKey ? { entityName: this.translateService.instant(`global.menu.entities.${entityKey}`) } : undefined;
this.addErrorAlert(errorHeader, errorHeader, alertData);
} else if (httpErrorResponse.error !== '' && httpErrorResponse.error.fieldErrors) {
this.handleFieldsError(httpErrorResponse);
} else if (httpErrorResponse.error !== '' && httpErrorResponse.error.message) {
this.addErrorAlert(
httpErrorResponse.error.detail ?? httpErrorResponse.error.message,
httpErrorResponse.error.message,
httpErrorResponse.error.params,
);
} else {
this.addErrorAlert(httpErrorResponse.error, httpErrorResponse.error);
}
}

private handleDefaultError(httpErrorResponse: HttpErrorResponse): void {
if (httpErrorResponse.error !== '' && httpErrorResponse.error.message) {
this.addErrorAlert(
httpErrorResponse.error.detail ?? httpErrorResponse.error.message,
httpErrorResponse.error.message,
httpErrorResponse.error.params,
);
} else {
this.addErrorAlert(httpErrorResponse.error, httpErrorResponse.error);
}
}

private handleFieldsError(httpErrorResponse: HttpErrorResponse): void {
const { fieldErrors } = httpErrorResponse.error;
for (const fieldError of fieldErrors) {
if (['Min', 'Max', 'DecimalMin', 'DecimalMax'].includes(fieldError.message)) {
fieldError.message = 'Size';
}
// convert 'something[14].other[4].id' to 'something[].other[].id' so translations can be written to it
const convertedField: string = fieldError.field.replace(/\[\d*\]/g, '[]');
const fieldName: string = this.translateService.instant(`villeveyracApp.${fieldError.objectName as string}.${convertedField}`);
this.addErrorAlert(`Error on field "${fieldName}"`, `error.${fieldError.message as string}`, { fieldName });
}
}
}

+ 7
- 0
src/app/shared/alert/alert-error.model.ts Ver arquivo

@@ -0,0 +1,7 @@
export class AlertError {
constructor(
public message: string,
public key?: string,
public params?: Record<string, unknown>,
) {}
}

+ 11
- 0
src/app/shared/alert/alert.component.html Ver arquivo

@@ -0,0 +1,11 @@
<div class="alerts" role="alert">
@for (alert of alerts(); track $index) {
<div [ngClass]="setClasses(alert)">
@if (alert.message) {
<ngb-alert [type]="alert.type" (closed)="close(alert)">
<pre [innerHTML]="alert.message"></pre>
</ngb-alert>
}
</div>
}
</div>

+ 44
- 0
src/app/shared/alert/alert.component.spec.ts Ver arquivo

@@ -0,0 +1,44 @@
jest.mock('app/core/util/alert.service');

import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';

import { AlertService } from 'app/core/util/alert.service';

import { AlertComponent } from './alert.component';

describe('Alert Component', () => {
let comp: AlertComponent;
let fixture: ComponentFixture<AlertComponent>;
let mockAlertService: AlertService;

beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [AlertComponent],
providers: [AlertService],
})
.overrideTemplate(AlertComponent, '')
.compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(AlertComponent);
comp = fixture.componentInstance;
mockAlertService = TestBed.inject(AlertService);
});

it('should call alertService.get on init', () => {
// WHEN
comp.ngOnInit();

// THEN
expect(mockAlertService.get).toHaveBeenCalled();
});

it('should call alertService.clear on destroy', () => {
// WHEN
comp.ngOnDestroy();

// THEN
expect(mockAlertService.clear).toHaveBeenCalled();
});
});

+ 36
- 0
src/app/shared/alert/alert.component.ts Ver arquivo

@@ -0,0 +1,36 @@
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';

import { Alert, AlertService } from 'app/core/util/alert.service';

@Component({
selector: 'jhi-alert',
templateUrl: './alert.component.html',
imports: [CommonModule, NgbModule],
})
export class AlertComponent implements OnInit, OnDestroy {
alerts = signal<Alert[]>([]);

private readonly alertService = inject(AlertService);

ngOnInit(): void {
this.alerts.set(this.alertService.get());
}

setClasses(alert: Alert): Record<string, boolean> {
const classes = { 'jhi-toast': Boolean(alert.toast) };
if (alert.position) {
return { ...classes, [alert.position]: true };
}
return classes;
}

ngOnDestroy(): void {
this.alertService.clear();
}

close(alert: Alert): void {
alert.close?.(this.alerts());
}
}

+ 96
- 0
src/app/shared/auth/has-any-authority.directive.spec.ts Ver arquivo

@@ -0,0 +1,96 @@
jest.mock('app/core/auth/account.service');

import { Component, ElementRef, WritableSignal, signal, viewChild } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';

import { AccountService } from 'app/core/auth/account.service';
import { Account } from 'app/core/auth/account.model';

import HasAnyAuthorityDirective from './has-any-authority.directive';

@Component({
imports: [HasAnyAuthorityDirective],
template: ` <div *jhiHasAnyAuthority="'ROLE_ADMIN'" #content></div> `,
})
class TestHasAnyAuthorityDirectiveComponent {
content = viewChild<ElementRef>('content');
}

describe('HasAnyAuthorityDirective tests', () => {
let mockAccountService: AccountService;
let currentAccount: WritableSignal<Account | null>;

beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [TestHasAnyAuthorityDirectiveComponent, TranslateModule.forRoot()],
providers: [provideHttpClient(), AccountService],
});
}));

beforeEach(() => {
mockAccountService = TestBed.inject(AccountService);
currentAccount = signal<Account | null>({ activated: true, authorities: [] } as any);
mockAccountService.trackCurrentAccount = jest.fn(() => currentAccount);
});

describe('set jhiHasAnyAuthority', () => {
it('should show restricted content to user if user has required role', () => {
// GIVEN
mockAccountService.hasAnyAuthority = jest.fn(() => true);
const fixture = TestBed.createComponent(TestHasAnyAuthorityDirectiveComponent);
const comp = fixture.componentInstance;

// WHEN
fixture.detectChanges();

// THEN
expect(comp.content).toBeDefined();
});

it('should not show restricted content to user if user has not required role', () => {
// GIVEN
mockAccountService.hasAnyAuthority = jest.fn(() => false);
const fixture = TestBed.createComponent(TestHasAnyAuthorityDirectiveComponent);
const comp = fixture.componentInstance;

// WHEN
fixture.detectChanges();

// THEN
expect(comp.content()).toBeUndefined();
});
});

describe('change authorities', () => {
it('should show or not show restricted content correctly if user authorities are changing', () => {
// GIVEN
mockAccountService.hasAnyAuthority = jest.fn((): boolean => Boolean(currentAccount()));
const fixture = TestBed.createComponent(TestHasAnyAuthorityDirectiveComponent);
const comp = fixture.componentInstance;

// WHEN
fixture.detectChanges();

// THEN
expect(comp.content()).toBeDefined();

// GIVEN
currentAccount.set(null);

// WHEN
fixture.detectChanges();

// THEN
expect(comp.content()).toBeUndefined();

// WHEN
currentAccount.set({ activated: true, authorities: ['foo'] } as any);
fixture.detectChanges();

// THEN
expect(comp.content).toBeDefined();
});
});
});

+ 38
- 0
src/app/shared/auth/has-any-authority.directive.ts Ver arquivo

@@ -0,0 +1,38 @@
import { Directive, TemplateRef, ViewContainerRef, computed, effect, inject, input } from '@angular/core';

import { AccountService } from 'app/core/auth/account.service';

/**
* @whatItDoes Conditionally includes an HTML element if current user has any
* of the authorities passed as the `expression`.
*
* @howToUse
* ```
* <some-element *jhiHasAnyAuthority="'ROLE_ADMIN'">...</some-element>
*
* <some-element *jhiHasAnyAuthority="['ROLE_ADMIN', 'ROLE_USER']">...</some-element>
* ```
*/
@Directive({
selector: '[jhiHasAnyAuthority]',
})
export default class HasAnyAuthorityDirective {
public authorities = input<string | string[]>([], { alias: 'jhiHasAnyAuthority' });

private readonly templateRef = inject(TemplateRef<any>);
private readonly viewContainerRef = inject(ViewContainerRef);

constructor() {
const accountService = inject(AccountService);
const currentAccount = accountService.trackCurrentAccount();
const hasPermission = computed(() => currentAccount()?.authorities && accountService.hasAnyAuthority(this.authorities()));

effect(() => {
if (hasPermission()) {
this.viewContainerRef.createEmbeddedView(this.templateRef);
} else {
this.viewContainerRef.clear();
}
});
}
}

+ 15
- 0
src/app/shared/date/duration.pipe.ts Ver arquivo

@@ -0,0 +1,15 @@
import { Pipe, PipeTransform } from '@angular/core';

import dayjs from 'dayjs/esm';

@Pipe({
name: 'duration',
})
export default class DurationPipe implements PipeTransform {
transform(value: any): string {
if (value) {
return dayjs.duration(value).humanize();
}
return '';
}
}

+ 19
- 0
src/app/shared/date/format-medium-date.pipe.spec.ts Ver arquivo

@@ -0,0 +1,19 @@
import dayjs from 'dayjs/esm';

import FormatMediumDatePipe from './format-medium-date.pipe';

describe('FormatMediumDatePipe', () => {
const formatMediumDatePipe = new FormatMediumDatePipe();

it('should return an empty string when receive undefined', () => {
expect(formatMediumDatePipe.transform(undefined)).toBe('');
});

it('should return an empty string when receive null', () => {
expect(formatMediumDatePipe.transform(null)).toBe('');
});

it('should format date like this D MMM YYYY', () => {
expect(formatMediumDatePipe.transform(dayjs('2020-11-16').locale('fr'))).toBe('16 Nov 2020');
});
});

+ 12
- 0
src/app/shared/date/format-medium-date.pipe.ts Ver arquivo

@@ -0,0 +1,12 @@
import { Pipe, PipeTransform } from '@angular/core';

import dayjs from 'dayjs/esm';

@Pipe({
name: 'formatMediumDate',
})
export default class FormatMediumDatePipe implements PipeTransform {
transform(day: dayjs.Dayjs | null | undefined): string {
return day ? day.format('D MMM YYYY') : '';
}
}

+ 19
- 0
src/app/shared/date/format-medium-datetime.pipe.spec.ts Ver arquivo

@@ -0,0 +1,19 @@
import dayjs from 'dayjs/esm';

import FormatMediumDatetimePipe from './format-medium-datetime.pipe';

describe('FormatMediumDatePipe', () => {
const formatMediumDatetimePipe = new FormatMediumDatetimePipe();

it('should return an empty string when receive undefined', () => {
expect(formatMediumDatetimePipe.transform(undefined)).toBe('');
});

it('should return an empty string when receive null', () => {
expect(formatMediumDatetimePipe.transform(null)).toBe('');
});

it('should format date like this D MMM YYYY', () => {
expect(formatMediumDatetimePipe.transform(dayjs('2020-11-16').locale('fr'))).toBe('16 Nov 2020 00:00:00');
});
});

+ 12
- 0
src/app/shared/date/format-medium-datetime.pipe.ts Ver arquivo

@@ -0,0 +1,12 @@
import { Pipe, PipeTransform } from '@angular/core';

import dayjs from 'dayjs/esm';

@Pipe({
name: 'formatMediumDatetime',
})
export default class FormatMediumDatetimePipe implements PipeTransform {
transform(day: dayjs.Dayjs | null | undefined): string {
return day ? day.format('D MMM YYYY HH:mm:ss') : '';
}
}

+ 3
- 0
src/app/shared/date/index.ts Ver arquivo

@@ -0,0 +1,3 @@
export { default as DurationPipe } from './duration.pipe';
export { default as FormatMediumDatePipe } from './format-medium-date.pipe';
export { default as FormatMediumDatetimePipe } from './format-medium-datetime.pipe';

+ 20
- 0
src/app/shared/filter/filter.component.html Ver arquivo

@@ -0,0 +1,20 @@
@if (filters().hasAnyFilterSet()) {
<div class="filter-display">
<span jhiTranslate="entity.filters.set">Following filters are set</span>
<button class="btn" (click)="clearAllFilters()" (keydown.enter)="clearAllFilters()">
<fa-icon icon="times" title="{{ 'entity.filters.clearAll' | translate }}"></fa-icon>
</button>
<ul>
@for (filterOption of filters().filterOptions; track filterOption.name) {
@for (value of filterOption.values; track value) {
<li>
<span>{{ filterOption.name }}:</span> {{ value }}
<button class="btn" (click)="clearFilter(filterOption.name, value)" (keydown.enter)="clearFilter(filterOption.name, value)">
<fa-icon icon="times" title="{{ 'entity.filters.clear' | translate }}"></fa-icon>
</button>
</li>
}
}
</ul>
</div>
}

+ 20
- 0
src/app/shared/filter/filter.component.ts Ver arquivo

@@ -0,0 +1,20 @@
import { Component, input } from '@angular/core';
import SharedModule from '../shared.module';
import { IFilterOptions } from './filter.model';

@Component({
selector: 'jhi-filter',
imports: [SharedModule],
templateUrl: './filter.component.html',
})
export default class FilterComponent {
readonly filters = input.required<IFilterOptions>();

clearAllFilters(): void {
this.filters().clear();
}

clearFilter(filterName: string, value: string): void {
this.filters().removeFilter(filterName, value);
}
}

+ 242
- 0
src/app/shared/filter/filter.model.spec.ts Ver arquivo

@@ -0,0 +1,242 @@
import { ParamMap, Params, convertToParamMap } from '@angular/router';
import { FilterOption, FilterOptions } from './filter.model';

describe('FilterModel Tests', () => {
describe('FilterOption', () => {
let filterOption: FilterOption;

beforeEach(() => {
filterOption = new FilterOption('foo', ['bar', 'bar2']);
});

it('nameAsQueryParam returns query key', () => {
expect(filterOption.nameAsQueryParam()).toEqual('filter[foo]');
});

describe('addValue', () => {
it('adds multiples unique values and returns true', () => {
const ret = filterOption.addValue('bar2', 'bar3', 'bar4');
expect(filterOption.values).toMatchObject(['bar', 'bar2', 'bar3', 'bar4']);
expect(ret).toBe(true);
});
it("doesn't adds duplicated values and return false", () => {
const ret = filterOption.addValue('bar', 'bar2');
expect(filterOption.values).toMatchObject(['bar', 'bar2']);
expect(ret).toBe(false);
});
});

describe('removeValue', () => {
it('removes the exiting value and return true', () => {
const ret = filterOption.removeValue('bar');
expect(filterOption.values).toMatchObject(['bar2']);
expect(ret).toBe(true);
});
it("doesn't removes the value and return false", () => {
const ret = filterOption.removeValue('foo');
expect(filterOption.values).toMatchObject(['bar', 'bar2']);
expect(ret).toBe(false);
});
});

describe('equals', () => {
it('returns true to matching options', () => {
const otherFilterOption = new FilterOption(filterOption.name, filterOption.values.concat());
expect(filterOption.equals(otherFilterOption)).toBe(true);
expect(otherFilterOption.equals(filterOption)).toBe(true);
});
it('returns false to different name', () => {
const otherFilterOption = new FilterOption('bar', filterOption.values.concat());
expect(filterOption.equals(otherFilterOption)).toBe(false);
expect(otherFilterOption.equals(filterOption)).toBe(false);
});
it('returns false to different values', () => {
const otherFilterOption = new FilterOption('bar', []);
expect(filterOption.equals(otherFilterOption)).toBe(false);
expect(otherFilterOption.equals(filterOption)).toBe(false);
});
});
});

describe('FilterOptions', () => {
describe('hasAnyFilterSet', () => {
it('with empty options returns false', () => {
const filters = new FilterOptions();
expect(filters.hasAnyFilterSet()).toBe(false);
});
it('with options and empty values returns false', () => {
const filters = new FilterOptions([new FilterOption('foo'), new FilterOption('bar')]);
expect(filters.hasAnyFilterSet()).toBe(false);
});
it('with option and value returns true', () => {
const filters = new FilterOptions([new FilterOption('foo', ['bar'])]);
expect(filters.hasAnyFilterSet()).toBe(true);
});
});

describe('clear', () => {
it("removes empty filters and doesn't emit next element", () => {
const filters = new FilterOptions([new FilterOption('foo'), new FilterOption('bar')]);
jest.spyOn(filters.filterChanges, 'next');

filters.clear();

expect(filters.filterChanges.next).not.toHaveBeenCalled();
expect(filters.filterOptions).toMatchObject([]);
});
it('removes empty filters and emits next element', () => {
const filters = new FilterOptions([new FilterOption('foo', ['existingFoo1']), new FilterOption('bar')]);
jest.spyOn(filters.filterChanges, 'next');

filters.clear();

expect(filters.filterChanges.next).toHaveBeenCalledTimes(1);
expect(filters.filterOptions).toMatchObject([]);
});
});

describe('addFilter', () => {
it('adds a non existing FilterOption, returns true and emit next element', () => {
const filters = new FilterOptions([new FilterOption('foo', ['existingFoo1', 'existingFoo2']), new FilterOption('bar')]);
jest.spyOn(filters.filterChanges, 'next');

const result = filters.addFilter('addedFilter', 'addedValue');

expect(result).toBe(true);
expect(filters.filterChanges.next).toHaveBeenCalledTimes(1);
expect(filters.filterOptions).toMatchObject([
{ name: 'foo', values: ['existingFoo1', 'existingFoo2'] },
{ name: 'addedFilter', values: ['addedValue'] },
]);
});
it('adds a non existing value to FilterOption, returns true and emit next element', () => {
const filters = new FilterOptions([new FilterOption('foo', ['existingFoo1', 'existingFoo2']), new FilterOption('bar')]);
jest.spyOn(filters.filterChanges, 'next');

const result = filters.addFilter('foo', 'addedValue1', 'addedValue2');

expect(result).toBe(true);
expect(filters.filterChanges.next).toHaveBeenCalledTimes(1);
expect(filters.filterOptions).toMatchObject([
{ name: 'foo', values: ['existingFoo1', 'existingFoo2', 'addedValue1', 'addedValue2'] },
]);
});
it("doesn't add FilterOption values already added, returns false and doesn't emit next element", () => {
const filters = new FilterOptions([new FilterOption('foo', ['existingFoo1', 'existingFoo2']), new FilterOption('bar')]);
jest.spyOn(filters.filterChanges, 'next');

const result = filters.addFilter('foo', 'existingFoo1', 'existingFoo2');

expect(result).toBe(false);
expect(filters.filterChanges.next).not.toHaveBeenCalled();
expect(filters.filterOptions).toMatchObject([{ name: 'foo', values: ['existingFoo1', 'existingFoo2'] }]);
});
});

describe('removeFilter', () => {
it('removes an existing FilterOptions and returns true', () => {
const filters = new FilterOptions([new FilterOption('foo', ['existingFoo1', 'existingFoo2']), new FilterOption('bar')]);
jest.spyOn(filters.filterChanges, 'next');

const result = filters.removeFilter('foo', 'existingFoo1');

expect(result).toBe(true);
expect(filters.filterChanges.next).toHaveBeenCalledTimes(1);
expect(filters.filterOptions).toMatchObject([{ name: 'foo', values: ['existingFoo2'] }]);
});
it("doesn't remove a non existing FilterOptions values returns false", () => {
const filters = new FilterOptions([new FilterOption('foo', ['existingFoo1', 'existingFoo2']), new FilterOption('bar')]);
jest.spyOn(filters.filterChanges, 'next');

const result = filters.removeFilter('foo', 'nonExisting1');

expect(result).toBe(false);
expect(filters.filterChanges.next).not.toHaveBeenCalled();
expect(filters.filterOptions).toMatchObject([{ name: 'foo', values: ['existingFoo1', 'existingFoo2'] }]);
});
it("doesn't remove a non existing FilterOptions returns false", () => {
const filters = new FilterOptions([new FilterOption('foo', ['existingFoo1', 'existingFoo2']), new FilterOption('bar')]);
jest.spyOn(filters.filterChanges, 'next');

const result = filters.removeFilter('nonExisting', 'nonExisting1');

expect(result).toBe(false);
expect(filters.filterChanges.next).not.toHaveBeenCalled();
expect(filters.filterOptions).toMatchObject([{ name: 'foo', values: ['existingFoo1', 'existingFoo2'] }]);
});
});

describe('initializeFromParams', () => {
const oneValidParam: Params = {
test: 'blub',
'filter[hello.in]': 'world',
'filter[invalid': 'invalid',
filter_invalid2: 'invalid',
};

const noValidParam: Params = {
test: 'blub',
'filter[invalid': 'invalid',
filter_invalid2: 'invalid',
};

const paramWithTwoValues: Params = {
'filter[hello.in]': ['world', 'world2'],
};

const paramWithTwoKeys: Params = {
'filter[hello.in]': ['world', 'world2'],
'filter[hello.notIn]': ['world3', 'world4'],
};

it('should parse from Params if there are any and not emit next element', () => {
const filters: FilterOptions = new FilterOptions([new FilterOption('foo', ['bar'])]);
jest.spyOn(filters.filterChanges, 'next');
const paramMap: ParamMap = convertToParamMap(oneValidParam);

filters.initializeFromParams(paramMap);

expect(filters.filterChanges.next).not.toHaveBeenCalled();
expect(filters.filterOptions).toMatchObject([{ name: 'hello.in', values: ['world'] }]);
});

it('should parse from Params and have none if there are none', () => {
const filters: FilterOptions = new FilterOptions();
const paramMap: ParamMap = convertToParamMap(noValidParam);
jest.spyOn(filters.filterChanges, 'next');

filters.initializeFromParams(paramMap);

expect(filters.filterChanges.next).not.toHaveBeenCalled();
expect(filters.filterOptions).toMatchObject([]);
});

it('should parse from Params and have a parameter with 2 values and one additional value', () => {
const filters: FilterOptions = new FilterOptions([new FilterOption('hello.in', ['world'])]);
jest.spyOn(filters.filterChanges, 'next');

const paramMap: ParamMap = convertToParamMap(paramWithTwoValues);

filters.initializeFromParams(paramMap);

expect(filters.filterChanges.next).not.toHaveBeenCalled();
expect(filters.filterOptions).toMatchObject([{ name: 'hello.in', values: ['world', 'world2'] }]);
});

it('should parse from Params and have a parameter with 2 keys', () => {
const filters: FilterOptions = new FilterOptions();
jest.spyOn(filters.filterChanges, 'next');

const paramMap: ParamMap = convertToParamMap(paramWithTwoKeys);

filters.initializeFromParams(paramMap);

expect(filters.filterChanges.next).not.toHaveBeenCalled();
expect(filters.filterOptions).toMatchObject([
{ name: 'hello.in', values: ['world', 'world2'] },
{ name: 'hello.notIn', values: ['world3', 'world4'] },
]);
});
});
});
});

+ 158
- 0
src/app/shared/filter/filter.model.ts Ver arquivo

@@ -0,0 +1,158 @@
import { ParamMap } from '@angular/router';
import { Subject } from 'rxjs';

export interface IFilterOptions {
readonly filterChanges: Subject<FilterOption[]>;
get filterOptions(): IFilterOption[];
hasAnyFilterSet(): boolean;
clear(): boolean;
initializeFromParams(params: ParamMap): boolean;
addFilter(name: string, ...values: string[]): boolean;
removeFilter(name: string, value: string): boolean;
}

export interface IFilterOption {
name: string;
values: string[];
nameAsQueryParam(): string;
}

export class FilterOption implements IFilterOption {
constructor(
public name: string,
public values: string[] = [],
) {
this.values = [...new Set(values)];
}

nameAsQueryParam(): string {
return `filter[${this.name}]`;
}

isSet(): boolean {
return this.values.length > 0;
}

addValue(...values: string[]): boolean {
const missingValues = values.filter(value => value && !this.values.includes(value));
if (missingValues.length > 0) {
this.values.push(...missingValues);
return true;
}
return false;
}

removeValue(value: string): boolean {
const indexOf = this.values.indexOf(value);
if (indexOf === -1) {
return false;
}

this.values.splice(indexOf, 1);
return true;
}

clone(): FilterOption {
return new FilterOption(this.name, this.values.concat());
}

equals(other: IFilterOption): boolean {
return (
this.name === other.name &&
this.values.length === other.values.length &&
this.values.every(thisValue => other.values.includes(thisValue)) &&
other.values.every(otherValue => this.values.includes(otherValue))
);
}
}

export class FilterOptions implements IFilterOptions {
readonly filterChanges = new Subject<FilterOption[]>();
private _filterOptions: FilterOption[];

constructor(filterOptions: FilterOption[] = []) {
this._filterOptions = filterOptions;
}

get filterOptions(): FilterOption[] {
return this._filterOptions.filter(option => option.isSet());
}

hasAnyFilterSet(): boolean {
return this._filterOptions.some(e => e.isSet());
}

clear(): boolean {
const hasFields = this.hasAnyFilterSet();
this._filterOptions = [];
if (hasFields) {
this.changed();
}
return hasFields;
}

initializeFromParams(params: ParamMap): boolean {
const oldFilters: FilterOptions = this.clone();

this._filterOptions = [];

const filterRegex = /filter\[(.+)\]/;
params.keys
.filter(paramKey => filterRegex.test(paramKey))
.forEach(matchingParam => {
const matches = filterRegex.exec(matchingParam);
if (matches && matches.length > 1) {
this.getFilterOptionByName(matches[1], true).addValue(...params.getAll(matchingParam));
}
});

if (oldFilters.equals(this)) {
return false;
}
return true;
}

addFilter(name: string, ...values: string[]): boolean {
if (this.getFilterOptionByName(name, true).addValue(...values)) {
this.changed();
return true;
}
return false;
}

removeFilter(name: string, value: string): boolean {
if (this.getFilterOptionByName(name)?.removeValue(value)) {
this.changed();
return true;
}
return false;
}

protected changed(): void {
this.filterChanges.next(this.filterOptions.map(option => option.clone()));
}

protected equals(other: FilterOptions): boolean {
const thisFilters = this.filterOptions;
const otherFilters = other.filterOptions;
if (thisFilters.length !== otherFilters.length) {
return false;
}
return thisFilters.every(option => other.getFilterOptionByName(option.name)?.equals(option));
}

protected clone(): FilterOptions {
return new FilterOptions(this.filterOptions.map(option => new FilterOption(option.name, option.values.concat())));
}

protected getFilterOptionByName(name: string, add: true): FilterOption;
protected getFilterOptionByName(name: string, add?: false): FilterOption | null;
protected getFilterOptionByName(name: string, add = false): FilterOption | null {
const addOption = (option: FilterOption): FilterOption => {
this._filterOptions.push(option);
return option;
};

return this._filterOptions.find(thisOption => thisOption.name === name) ?? (add ? addOption(new FilterOption(name)) : null);
}
}

+ 2
- 0
src/app/shared/filter/index.ts Ver arquivo

@@ -0,0 +1,2 @@
export { default as FilterComponent } from './filter.component';
export * from './filter.model';

+ 16
- 0
src/app/shared/language/find-language-from-key.pipe.ts Ver arquivo

@@ -0,0 +1,16 @@
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
name: 'findLanguageFromKey',
})
export default class FindLanguageFromKeyPipe implements PipeTransform {
private readonly languages: Record<string, { name: string; rtl?: boolean }> = {
fr: { name: 'Français' },
'pt-pt': { name: 'Português' },
// jhipster-needle-i18n-language-key-pipe - JHipster will add/remove languages in this object
};

transform(lang: string): string {
return this.languages[lang].name;
}
}

+ 2
- 0
src/app/shared/language/index.ts Ver arquivo

@@ -0,0 +1,2 @@
export { default as TranslateDirective } from './translate.directive';
export { default as FindLanguageFromKeyPipe } from './find-language-from-key.pipe';

+ 35
- 0
src/app/shared/language/translate.directive.spec.ts Ver arquivo

@@ -0,0 +1,35 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';

import TranslateDirective from './translate.directive';

@Component({
imports: [TranslateDirective],
template: ` <div jhiTranslate="test"></div> `,
})
class TestTranslateDirectiveComponent {}

describe('TranslateDirective Tests', () => {
let fixture: ComponentFixture<TestTranslateDirectiveComponent>;
let translateService: TranslateService;

beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), TestTranslateDirectiveComponent],
});
}));

beforeEach(() => {
translateService = TestBed.inject(TranslateService);
fixture = TestBed.createComponent(TestTranslateDirectiveComponent);
});

it('should change HTML', () => {
const spy = jest.spyOn(translateService, 'get');

fixture.detectChanges();

expect(spy).toHaveBeenCalled();
});
});

+ 52
- 0
src/app/shared/language/translate.directive.ts Ver arquivo

@@ -0,0 +1,52 @@
import { Directive, ElementRef, OnChanges, OnDestroy, OnInit, inject, input } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

import { translationNotFoundMessage } from 'app/config/translation.config';

/**
* A wrapper directive on top of the translation pipe as the inbuilt translation directive from ngx-translate is too verbose and buggy
*/
@Directive({
selector: '[jhiTranslate]',
})
export default class TranslateDirective implements OnChanges, OnInit, OnDestroy {
readonly jhiTranslate = input.required<string>();
readonly translateValues = input<Record<string, unknown>>();

private readonly directiveDestroyed = new Subject();

private readonly el = inject(ElementRef);
private readonly translateService = inject(TranslateService);

ngOnInit(): void {
this.translateService.onLangChange.pipe(takeUntil(this.directiveDestroyed)).subscribe(() => {
this.getTranslation();
});
this.translateService.onTranslationChange.pipe(takeUntil(this.directiveDestroyed)).subscribe(() => {
this.getTranslation();
});
}

ngOnChanges(): void {
this.getTranslation();
}

ngOnDestroy(): void {
this.directiveDestroyed.next(null);
this.directiveDestroyed.complete();
}

private getTranslation(): void {
this.translateService
.get(this.jhiTranslate(), this.translateValues())
.pipe(takeUntil(this.directiveDestroyed))
.subscribe({
next: value => {
this.el.nativeElement.innerHTML = value;
},
error: () => `${translationNotFoundMessage}[${this.jhiTranslate()}]`,
});
}
}

+ 32
- 0
src/app/shared/language/translation.module.ts Ver arquivo

@@ -0,0 +1,32 @@
import { NgModule, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { MissingTranslationHandler, TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
import { missingTranslationHandler, translatePartialLoader } from 'app/config/translation.config';
import { StateStorageService } from 'app/core/auth/state-storage.service';

@NgModule({
imports: [
TranslateModule.forRoot({
loader: {
provide: TranslateLoader,
useFactory: translatePartialLoader,
deps: [HttpClient],
},
missingTranslationHandler: {
provide: MissingTranslationHandler,
useFactory: missingTranslationHandler,
},
}),
],
})
export class TranslationModule {
private readonly translateService = inject(TranslateService);
private readonly stateStorageService = inject(StateStorageService);

constructor() {
this.translateService.setDefaultLang('fr');
// if user have changed language and navigates away from the application and back to the application then use previously chosen language
const langKey = this.stateStorageService.getLocale() ?? 'fr';
this.translateService.use(langKey);
}
}

+ 1
- 0
src/app/shared/pagination/index.ts Ver arquivo

@@ -0,0 +1 @@
export { default as ItemCountComponent } from './item-count.component';

+ 71
- 0
src/app/shared/pagination/item-count.component.spec.ts Ver arquivo

@@ -0,0 +1,71 @@
import { ComponentRef } from '@angular/core';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';

import TranslateDirective from 'app/shared/language/translate.directive';

import ItemCountComponent from './item-count.component';

describe('ItemCountComponent test', () => {
let comp: ItemCountComponent;
let compRef: ComponentRef<ItemCountComponent>;
let fixture: ComponentFixture<ItemCountComponent>;
const inputParams = 'params';

beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [ItemCountComponent, TranslateModule.forRoot(), TranslateDirective],
}).compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(ItemCountComponent);
comp = fixture.componentInstance;
compRef = fixture.componentRef;
});

describe('UI logic tests', () => {
it('should initialize with undefined', () => {
expect(comp.first()).toBeUndefined();
expect(comp.second()).toBeUndefined();
expect(comp.total()).toBeUndefined();
});

it('should set calculated numbers to undefined if the page value is not yet defined', () => {
// GIVEN
compRef.setInput(inputParams, { page: undefined, totalItems: 0, itemsPerPage: 10 });

// THEN
expect(comp.first()).toBeUndefined();
expect(comp.second()).toBeUndefined();
});

it('should change the content on page change', () => {
// GIVEN
compRef.setInput(inputParams, { page: 1, totalItems: 100, itemsPerPage: 10 });

// THEN
expect(comp.first()).toBe(1);
expect(comp.second()).toBe(10);
expect(comp.total()).toBe(100);

// GIVEN
compRef.setInput(inputParams, { page: 2, totalItems: 100, itemsPerPage: 10 });

// THEN
expect(comp.first()).toBe(11);
expect(comp.second()).toBe(20);
expect(comp.total()).toBe(100);
});

it('should set the second number to totalItems if this is the last page which contains less than itemsPerPage items', () => {
// GIVEN
compRef.setInput(inputParams, { page: 2, totalItems: 16, itemsPerPage: 10 });

// THEN
expect(comp.first()).toBe(11);
expect(comp.second()).toBe(16);
expect(comp.total()).toBe(16);
});
});
});

+ 42
- 0
src/app/shared/pagination/item-count.component.ts Ver arquivo

@@ -0,0 +1,42 @@
import { Component, computed, input } from '@angular/core';
import TranslateDirective from '../language/translate.directive';

/**
* A component that will take care of item count statistics of a pagination.
*/
@Component({
selector: 'jhi-item-count',
template: ` <div jhiTranslate="global.item-count" [translateValues]="{ first: first(), second: second(), total: total() }"></div> `,
imports: [TranslateDirective],
})
export default class ItemCountComponent {
/**
* @param params Contains parameters for component:
* page Current page number
* totalItems Total number of items
* itemsPerPage Number of items per page
*/
params = input<{
page?: number;
totalItems?: number;
itemsPerPage?: number;
}>();

first = computed(() => {
const params = this.params();
if (params?.page && params.totalItems !== undefined && params.itemsPerPage) {
return (params.page - 1) * params.itemsPerPage + 1;
}
return undefined;
});

second = computed(() => {
const params = this.params();
if (params?.page && params.totalItems !== undefined && params.itemsPerPage) {
return params.page * params.itemsPerPage < params.totalItems ? params.page * params.itemsPerPage : params.totalItems;
}
return undefined;
});

total = computed(() => this.params()?.totalItems);
}

+ 28
- 0
src/app/shared/shared.module.ts Ver arquivo

@@ -0,0 +1,28 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { TranslateModule } from '@ngx-translate/core';

import FindLanguageFromKeyPipe from './language/find-language-from-key.pipe';
import TranslateDirective from './language/translate.directive';
import { AlertComponent } from './alert/alert.component';
import { AlertErrorComponent } from './alert/alert-error.component';

/**
* Application wide Module
*/
@NgModule({
imports: [AlertComponent, AlertErrorComponent, FindLanguageFromKeyPipe, TranslateDirective],
exports: [
CommonModule,
NgbModule,
FontAwesomeModule,
AlertComponent,
AlertErrorComponent,
TranslateModule,
FindLanguageFromKeyPipe,
TranslateDirective,
],
})
export default class SharedModule {}

+ 4
- 0
src/app/shared/sort/index.ts Ver arquivo

@@ -0,0 +1,4 @@
export * from './sort-by.directive';
export * from './sort-state';
export * from './sort.directive';
export * from './sort.service';

+ 119
- 0
src/app/shared/sort/sort-by.directive.spec.ts Ver arquivo

@@ -0,0 +1,119 @@
import { Component, DebugElement, Type, inject } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { FaIconComponent, FaIconLibrary } from '@fortawesome/angular-fontawesome';
import { faSort, faSortDown, faSortUp, fas } from '@fortawesome/free-solid-svg-icons';

import { SortByDirective } from './sort-by.directive';
import { SortDirective } from './sort.directive';
import { sortStateSignal } from './sort-state';

@Component({
imports: [SortDirective, SortByDirective, FaIconComponent],
template: `
<table>
<thead>
<tr jhiSort [(sortState)]="sortState" (sortChange)="transition($event)">
<th jhiSortBy="name">
ID
<fa-icon [icon]="'sort'"></fa-icon>
</th>
</tr>
</thead>
</table>
`,
})
class TestSortByDirectiveComponent {
sortState = sortStateSignal({ predicate: 'name' });
transition = jest.fn();

private library = inject(FaIconLibrary);

constructor() {
this.library.addIconPacks(fas);
this.library.addIcons(faSort, faSortDown, faSortUp);
}
}

describe('Directive: SortByDirective', () => {
let component: TestSortByDirectiveComponent;
let fixture: ComponentFixture<TestSortByDirectiveComponent>;
let tableHead: DebugElement;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestSortByDirectiveComponent],
}).compileComponents();
fixture = TestBed.createComponent(TestSortByDirectiveComponent);
component = fixture.componentInstance;
tableHead = fixture.debugElement.query(By.directive(SortByDirective));
});

it('should have a neutral state for predicate column and undefined order value', () => {
// GIVEN
component.sortState.set({ predicate: 'name' });
const sortByDirective = tableHead.injector.get(SortByDirective as Type<SortByDirective>);

// WHEN
fixture.detectChanges();

// THEN
expect(sortByDirective.jhiSortBy()).toEqual('name');
expect(sortByDirective.iconComponent()?.icon).toEqual(faSort.iconName);
});

it('should have an asc state for predicate column and true asc value', () => {
// GIVEN
component.sortState.set({ predicate: 'name', order: 'asc' });
const sortByDirective = tableHead.injector.get(SortByDirective as Type<SortByDirective>);

// WHEN
fixture.detectChanges();

// THEN
expect(sortByDirective.jhiSortBy()).toEqual('name');
expect(sortByDirective.iconComponent()?.icon).toEqual(faSortUp.iconName);
});

it('should have a desc state for predicate column and desc value', () => {
// GIVEN
component.sortState.set({ predicate: 'name', order: 'desc' });
const sortByDirective = tableHead.injector.get(SortByDirective as Type<SortByDirective>);

// WHEN
fixture.detectChanges();

// THEN
expect(sortByDirective.jhiSortBy()).toEqual('name');
expect(sortByDirective.iconComponent()?.icon).toEqual(faSortDown.iconName);
});

it('should have a neutral state for non-predicate column', () => {
// GIVEN
component.sortState.set({ predicate: 'non-existing-column', order: 'asc' });
const sortByDirective = tableHead.injector.get(SortByDirective as Type<SortByDirective>);

// WHEN
fixture.detectChanges();

// THEN
expect(sortByDirective.jhiSortBy()).toEqual('name');
expect(sortByDirective.iconComponent()?.icon).toEqual(faSort.iconName);
});

it('multiple clicks at same component, should call SortDirective sort', () => {
// GIVEN
const sortDirective = tableHead.injector.get(SortDirective as Type<SortDirective>);
sortDirective.sort = jest.fn();

// WHEN
fixture.detectChanges();
tableHead.triggerEventHandler('click', null);
tableHead.triggerEventHandler('click', null);

// THEN
expect(sortDirective.sort).toHaveBeenCalledTimes(2);
expect(sortDirective.sort).toHaveBeenNthCalledWith(1, 'name');
expect(sortDirective.sort).toHaveBeenNthCalledWith(2, 'name');
});
});

+ 41
- 0
src/app/shared/sort/sort-by.directive.ts Ver arquivo

@@ -0,0 +1,41 @@
import { Directive, HostListener, contentChild, effect, inject, input } from '@angular/core';
import { IconDefinition, faSort, faSortDown, faSortUp } from '@fortawesome/free-solid-svg-icons';

import { SortDirective } from './sort.directive';
import {FaIconComponent} from '@fortawesome/angular-fontawesome';

@Directive({
selector: '[jhiSortBy]',
})
export class SortByDirective {
readonly jhiSortBy = input.required<string>();

iconComponent = contentChild(FaIconComponent);

protected sortIcon = faSort;
protected sortAscIcon = faSortUp;
protected sortDescIcon = faSortDown;

private readonly sort = inject(SortDirective, { host: true });

constructor() {
effect(() => {
if (this.iconComponent()) {
let icon: IconDefinition = this.sortIcon;
const { predicate, order } = this.sort.sortState();
if (predicate === this.jhiSortBy() && order !== undefined) {
icon = order === 'asc' ? this.sortAscIcon : this.sortDescIcon;
}
this.iconComponent()!.icon = icon.iconName;
this.iconComponent()!.render();
}
});
}

@HostListener('click')
onClick(): void {
if (this.iconComponent()) {
this.sort.sort(this.jhiSortBy());
}
}
}

+ 10
- 0
src/app/shared/sort/sort-state.ts Ver arquivo

@@ -0,0 +1,10 @@
import { WritableSignal, signal } from '@angular/core';

export type SortOrder = 'asc' | 'desc';

export type SortState = { predicate?: string; order?: SortOrder };

export const sortStateSignal = (state: SortState): WritableSignal<SortState> =>
signal<SortState>(state, {
equal: (a, b) => a.predicate === b.predicate && a.order === b.order,
});

+ 86
- 0
src/app/shared/sort/sort.directive.spec.ts Ver arquivo

@@ -0,0 +1,86 @@
import { Component, DebugElement, Type } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';

import { SortDirective } from './sort.directive';
import { SortState, sortStateSignal } from './sort-state';

@Component({
imports: [SortDirective],
template: `
<table>
<thead>
<tr jhiSort [(sortState)]="sortState" (sortChange)="transition($event)"></tr>
</thead>
</table>
`,
})
class TestSortDirectiveComponent {
sortState = sortStateSignal({ predicate: 'ID' });
transition = jest.fn().mockImplementation((sortState: SortState) => {
this.sortState.set(sortState);
});
}

describe('Directive: SortDirective', () => {
let component: TestSortDirectiveComponent;
let fixture: ComponentFixture<TestSortDirectiveComponent>;
let tableRow: DebugElement;

beforeEach(() => {
TestBed.configureTestingModule({
imports: [TestSortDirectiveComponent],
});
fixture = TestBed.createComponent(TestSortDirectiveComponent);
component = fixture.componentInstance;
tableRow = fixture.debugElement.query(By.directive(SortDirective));
});

it('should invoke sortChange function', () => {
// GIVEN
const sortDirective = tableRow.injector.get(SortDirective as Type<SortDirective>);

// WHEN
fixture.detectChanges();
sortDirective.sort('ID');

// THEN
expect(component.transition).toHaveBeenCalledTimes(1);
expect(component.transition).toHaveBeenCalledWith({ predicate: 'ID', order: 'asc' });
});

it('should change sort order to descending, neutral when same field is sorted again', () => {
// GIVEN
const sortDirective = tableRow.injector.get(SortDirective as Type<SortDirective>);

// WHEN
fixture.detectChanges();
sortDirective.sort('ID');
// sort again
sortDirective.sort('ID');
// sort again
sortDirective.sort('ID');

// THEN
expect(component.transition).toHaveBeenCalledTimes(3);
expect(component.transition).toHaveBeenNthCalledWith(1, { predicate: 'ID', order: 'asc' });
expect(component.transition).toHaveBeenNthCalledWith(2, { predicate: 'ID', order: 'desc' });
expect(component.transition).toHaveBeenNthCalledWith(3, { predicate: 'ID', order: 'asc' });
});

it('should change sort order to ascending when different field is sorted', () => {
// GIVEN
const sortDirective = tableRow.injector.get(SortDirective as Type<SortDirective>);

// WHEN
fixture.detectChanges();
sortDirective.sort('ID');
// sort again
sortDirective.sort('NAME');

// THEN
expect(component.transition).toHaveBeenCalledTimes(2);
expect(component.transition).toHaveBeenNthCalledWith(1, { predicate: 'ID', order: 'asc' });
expect(component.transition).toHaveBeenNthCalledWith(2, { predicate: 'NAME', order: 'asc' });
});
});

+ 19
- 0
src/app/shared/sort/sort.directive.ts Ver arquivo

@@ -0,0 +1,19 @@
import { Directive, model, output } from '@angular/core';
import { SortOrder, SortState } from './sort-state';

@Directive({
selector: '[jhiSort]',
})
export class SortDirective {
readonly sortState = model.required<SortState>();

readonly sortChange = output<SortState>();

sort(field: string): void {
const { predicate, order } = this.sortState();
const toggle = (): SortOrder => (order === 'asc' ? 'desc' : 'asc');
const newSortState = { predicate: field, order: field !== predicate ? 'asc' : toggle() };
this.sortState.update(() => newSortState);
this.sortChange.emit(newSortState);
}
}

+ 46
- 0
src/app/shared/sort/sort.service.spec.ts Ver arquivo

@@ -0,0 +1,46 @@
import { SortService } from './sort.service';

describe('sort state', () => {
const service = new SortService();

describe('parseSortParam', () => {
it('should accept undefined value', () => {
const sortState = service.parseSortParam(undefined);
expect(sortState).toEqual({});
});
it('should accept empty string', () => {
const sortState = service.parseSortParam('');
expect(sortState).toEqual({});
});
it('should accept predicate only string', () => {
const sortState = service.parseSortParam('predicate');
expect(sortState).toEqual({ predicate: 'predicate' });
});
it('should accept predicate and ASC string', () => {
const sortState = service.parseSortParam('predicate,asc');
expect(sortState).toEqual({ predicate: 'predicate', order: 'asc' });
});
it('should accept predicate and DESC string', () => {
const sortState = service.parseSortParam('predicate,desc');
expect(sortState).toEqual({ predicate: 'predicate', order: 'desc' });
});
});
describe('buildSortParam', () => {
it('should accept empty object', () => {
const sortParam = service.buildSortParam({});
expect(sortParam).toEqual([]);
});
it('should accept object with predicate', () => {
const sortParam = service.buildSortParam({ predicate: 'column' });
expect(sortParam).toEqual([]);
});
it('should accept object with predicate and asc value', () => {
const sortParam = service.buildSortParam({ predicate: 'column', order: 'asc' });
expect(sortParam).toEqual(['column,asc']);
});
it('should accept object with predicate and desc value', () => {
const sortParam = service.buildSortParam({ predicate: 'column', order: 'desc' });
expect(sortParam).toEqual(['column,desc']);
});
});
});

+ 39
- 0
src/app/shared/sort/sort.service.ts Ver arquivo

@@ -0,0 +1,39 @@
import { Injectable } from '@angular/core';
import { SortState } from './sort-state';

@Injectable({ providedIn: 'root' })
export class SortService {
private readonly collator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
});

startSort({ predicate, order }: Required<SortState>, fallback?: Required<SortState>): (a: any, b: any) => number {
const multiply = order === 'desc' ? -1 : 1;
return (a: any, b: any) => {
const compare = this.collator.compare(a[predicate], b[predicate]);
if (compare === 0 && fallback) {
return this.startSort(fallback)(a, b);
}
return compare * multiply;
};
}

parseSortParam(sortParam: string | undefined): SortState {
if (sortParam?.includes(',')) {
const split = sortParam.split(',');
if (split[0]) {
return { predicate: split[0], order: split[1] as any };
}
}
return { predicate: sortParam?.length ? sortParam : undefined };
}

buildSortParam({ predicate, order }: SortState, fallback?: string): string[] {
const sortParam = predicate && order ? [`${predicate},${order}`] : [];
if (fallback && predicate !== fallback) {
sortParam.push(`${fallback},asc`);
}
return sortParam;
}
}

+ 7
- 0
src/declarations.d.ts Ver arquivo

@@ -0,0 +1,7 @@
// These constants are injected via webpack DefinePlugin variables.
// You can add more variables in webpack.common.js or in profile specific webpack.<dev|prod>.js files.
// If you change the values in the webpack config files, you need to re run webpack to update the application

declare const __VERSION__: string;
declare const SERVER_API_URL: string;
declare const I18N_HASH: string;

+ 2
- 0
src/index.html Ver arquivo

@@ -6,6 +6,8 @@
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body>
<app-root></app-root>

+ 11
- 4
src/main.ts Ver arquivo

@@ -1,6 +1,13 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { App } from './app/app';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';

bootstrapApplication(App, appConfig)
.catch((err) => console.error(err));
import { AppComponent } from './app/app';
import routes from './app/app.routes';

bootstrapApplication(AppComponent, {
providers: [
provideHttpClient(),
provideRouter(routes),
],
}).catch(err => console.error(err));

+ 70
- 1
src/styles.scss Ver arquivo

@@ -1 +1,70 @@
/* You can add global styles to this file, and also import other style files */
/* Reset et styles de base */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

html, body {
height: 100%;
}

body {
font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: #f8f9fa;
color: #1a1a1a;
line-height: 1.6;
}
.main-layout{
display: flex;
flex-direction: row;
}
.content-area{
width: 100%;
}
/* Scrollbar personnalisée */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}

::-webkit-scrollbar-track {
background: #f1f1f1;
}

::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 4px;

&:hover {
background: #999;
}
}

/* Utilitaires */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}

.text-center {
text-align: center;
}

.mt-1 { margin-top: 0.5rem; }
.mt-2 { margin-top: 1rem; }
.mt-3 { margin-top: 1.5rem; }
.mt-4 { margin-top: 2rem; }

.mb-1 { margin-bottom: 0.5rem; }
.mb-2 { margin-bottom: 1rem; }
.mb-3 { margin-bottom: 1.5rem; }
.mb-4 { margin-bottom: 2rem; }

.p-1 { padding: 0.5rem; }
.p-2 { padding: 1rem; }
.p-3 { padding: 1.5rem; }
.p-4 { padding: 2rem; }

+ 5
- 5
tsconfig.app.json Ver arquivo

@@ -4,12 +4,12 @@
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
"types": ["node"]
},
"include": [
"src/**/*.ts"
"files": [
"src/main.ts"
],
"exclude": [
"src/**/*.spec.ts"
"include": [
"src/**/*.d.ts"
]
}

+ 12
- 12
tsconfig.json Ver arquivo

@@ -3,6 +3,7 @@
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
@@ -10,25 +11,24 @@
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "preserve"
"module": "ES2022",
"lib": [
"ES2022",
"dom"
],
"types": ["node"]
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}
}

Carregando…
Cancelar
Salvar