geonetwork-ui 2.10.0-dev.e97ea6950 → 2.10.0-dev.f1c00d5fe

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/fesm2022/geonetwork-ui.mjs +2558 -6985
  2. package/fesm2022/geonetwork-ui.mjs.map +1 -1
  3. package/index.d.ts +299 -189
  4. package/index.d.ts.map +1 -1
  5. package/package.json +25 -35
  6. package/src/libs/api/metadata-converter/src/lib/dcat-ap/dcat-ap.converter.ts +20 -0
  7. package/src/libs/api/metadata-converter/src/lib/fixtures/eu.dcat-ap.records.ts +4 -0
  8. package/src/libs/api/metadata-converter/src/lib/fixtures/generic.records.ts +23 -0
  9. package/src/libs/api/metadata-converter/src/lib/fixtures/geo2france.records.reuse+ongules.ts +8 -0
  10. package/src/libs/api/metadata-converter/src/lib/fixtures/geo2france.records.reuse+roilaye.ts +2 -0
  11. package/src/libs/api/metadata-converter/src/lib/fixtures/geo2france.records.ts +2 -0
  12. package/src/libs/api/metadata-converter/src/lib/fixtures/geocat-ch.records.ts +2 -0
  13. package/src/libs/api/metadata-converter/src/lib/fixtures/georhena.records.ts +2 -0
  14. package/src/libs/api/metadata-converter/src/lib/fixtures/metadata-for-i18n.records.ts +4 -0
  15. package/src/libs/api/metadata-converter/src/lib/fixtures/metawal.records.ts +23 -0
  16. package/src/libs/api/metadata-converter/src/lib/fixtures/opendataswiss.records.ts +2 -0
  17. package/src/libs/api/metadata-converter/src/lib/fixtures/sextant.records.ts +4 -0
  18. package/src/libs/api/metadata-converter/src/lib/fixtures/vlaanderen.dcat-ap.records.ts +2 -0
  19. package/src/libs/api/metadata-converter/src/lib/fixtures/wallonie.records.reuse.ts +9 -0
  20. package/src/libs/api/metadata-converter/src/lib/gn4/atomic-operations.ts +48 -7
  21. package/src/libs/api/metadata-converter/src/lib/gn4/gn4.converter.ts +2 -0
  22. package/src/libs/api/metadata-converter/src/lib/gn4/gn4.field.mapper.ts +6 -5
  23. package/src/libs/api/metadata-converter/src/lib/gn4/metadata-url.service.ts +2 -2
  24. package/src/libs/api/metadata-converter/src/lib/gn4/types/metadata.model.ts +1 -0
  25. package/src/libs/api/metadata-converter/src/lib/iso19115-3/iso19115-3.converter.ts +11 -0
  26. package/src/libs/api/metadata-converter/src/lib/iso19115-3/read-parts.ts +21 -0
  27. package/src/libs/api/metadata-converter/src/lib/iso19115-3/write-parts.ts +52 -0
  28. package/src/libs/api/metadata-converter/src/lib/iso19139/iso19139.converter.ts +24 -1
  29. package/src/libs/api/metadata-converter/src/lib/iso19139/read-parts.ts +73 -0
  30. package/src/libs/api/metadata-converter/src/lib/iso19139/write-parts.ts +79 -0
  31. package/src/libs/api/repository/src/lib/gn4/auth/auth.service.ts +8 -4
  32. package/src/libs/api/repository/src/lib/gn4/elasticsearch/elasticsearch.service.ts +2 -2
  33. package/src/libs/api/repository/src/lib/gn4/gn4-repository.ts +79 -41
  34. package/src/libs/api/repository/src/lib/gn4/gn4.provider.ts +6 -1
  35. package/src/libs/api/repository/src/lib/gn4/organizations/organizations-from-groups.service.ts +1 -1
  36. package/src/libs/api/repository/src/lib/gn4/organizations/organizations-from-metadata.service.ts +1 -1
  37. package/src/libs/api/repository/src/lib/gn4/platform/gn4-platform.service.ts +37 -2
  38. package/src/libs/common/domain/src/lib/model/record/metadata.model.ts +23 -5
  39. package/src/libs/common/domain/src/lib/model/thesaurus/thesaurus.model.ts +2 -1
  40. package/src/libs/common/domain/src/lib/model/user/group.model.ts +8 -0
  41. package/src/libs/common/domain/src/lib/model/user/index.ts +1 -0
  42. package/src/libs/common/domain/src/lib/platform.service.interface.ts +2 -0
  43. package/src/libs/common/domain/src/lib/repository/records-repository.interface.ts +2 -1
  44. package/src/libs/common/fixtures/src/lib/records.fixtures.ts +62 -0
  45. package/src/libs/feature/catalog/src/lib/sources/sources.service.ts +5 -2
  46. package/src/libs/feature/editor/src/index.ts +1 -0
  47. package/src/libs/feature/editor/src/lib/+state/editor.actions.ts +10 -1
  48. package/src/libs/feature/editor/src/lib/+state/editor.effects.ts +26 -20
  49. package/src/libs/feature/editor/src/lib/+state/editor.facade.ts +15 -2
  50. package/src/libs/feature/editor/src/lib/components/constraint-card/constraint-card.component.ts +0 -2
  51. package/src/libs/feature/editor/src/lib/components/contact-details/contact-details-form.component.css +0 -0
  52. package/src/libs/feature/editor/src/lib/components/contact-details/contact-details-form.component.html +68 -0
  53. package/src/libs/feature/editor/src/lib/components/contact-details/contact-details-form.component.ts +41 -0
  54. package/src/libs/feature/editor/src/lib/components/generic-keywords/generic-keywords.component.html +5 -14
  55. package/src/libs/feature/editor/src/lib/components/generic-keywords/generic-keywords.component.ts +3 -28
  56. package/src/libs/feature/editor/src/lib/components/metadata-quality-panel/metadata-quality-panel.component.html +18 -3
  57. package/src/libs/feature/editor/src/lib/components/metadata-quality-panel/metadata-quality-panel.component.ts +33 -40
  58. package/src/libs/feature/editor/src/lib/components/online-service-resource-input/online-service-resource-input.component.html +5 -5
  59. package/src/libs/feature/editor/src/lib/components/online-service-resource-input/online-service-resource-input.component.ts +2 -1
  60. package/src/libs/feature/editor/src/lib/components/record-form/form-field/field-focus.directive.ts +41 -0
  61. package/src/libs/feature/editor/src/lib/components/record-form/form-field/form-field-constraints-shortcuts/form-field-constraints-shortcuts.component.ts +33 -34
  62. package/src/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts/form-field-contacts.component.html +13 -13
  63. package/src/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts/form-field-contacts.component.ts +18 -4
  64. package/src/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts-for-resource/form-field-contacts-for-resource.component.html +8 -7
  65. package/src/libs/feature/editor/src/lib/components/record-form/form-field/form-field-contacts-for-resource/form-field-contacts-for-resource.component.ts +6 -6
  66. package/src/libs/feature/editor/src/lib/components/record-form/form-field/form-field-online-resources/form-field-online-resources.component.ts +1 -1
  67. package/src/libs/feature/editor/src/lib/components/record-form/form-field/form-field-online-single-link-resource/form-field-online-single-link-resource.component.css +0 -0
  68. package/src/libs/feature/editor/src/lib/components/record-form/form-field/form-field-online-single-link-resource/form-field-online-single-link-resource.component.html +5 -0
  69. package/src/libs/feature/editor/src/lib/components/record-form/form-field/form-field-online-single-link-resource/form-field-online-single-link-resource.component.ts +89 -0
  70. package/src/libs/feature/editor/src/lib/components/record-form/form-field/form-field-overviews/form-field-overviews.component.ts +12 -5
  71. package/src/libs/feature/editor/src/lib/components/record-form/form-field/form-field-rich/form-field-rich.component.html +4 -4
  72. package/src/libs/feature/editor/src/lib/components/record-form/form-field/form-field-rich/form-field-rich.component.ts +7 -0
  73. package/src/libs/feature/editor/src/lib/components/record-form/form-field/form-field-topics/form-field-topics.component.ts +2 -2
  74. package/src/libs/feature/editor/src/lib/components/record-form/form-field/form-field-update-frequency/form-field-update-frequency.component.html +6 -2
  75. package/src/libs/feature/editor/src/lib/components/record-form/form-field/form-field-update-frequency/form-field-update-frequency.component.ts +3 -1
  76. package/src/libs/feature/editor/src/lib/components/record-form/form-field/form-field.component.css +3 -0
  77. package/src/libs/feature/editor/src/lib/components/record-form/form-field/form-field.component.html +28 -16
  78. package/src/libs/feature/editor/src/lib/components/record-form/form-field/form-field.component.ts +16 -0
  79. package/src/libs/feature/editor/src/lib/components/record-form/form-field/index.ts +1 -0
  80. package/src/libs/feature/editor/src/lib/components/record-form/record-form.component.css +35 -0
  81. package/src/libs/feature/editor/src/lib/components/record-form/record-form.component.html +18 -5
  82. package/src/libs/feature/editor/src/lib/components/record-form/record-form.component.ts +118 -5
  83. package/src/libs/feature/editor/src/lib/expressions.ts +380 -8
  84. package/src/libs/feature/editor/src/lib/fields.config.ts +354 -337
  85. package/src/libs/feature/editor/src/lib/models/editor-config.model.ts +10 -3
  86. package/src/libs/feature/editor/src/lib/services/editor.service.ts +9 -13
  87. package/src/libs/feature/map/src/lib/utils/map-utils.service.ts +33 -0
  88. package/src/libs/feature/notify-reuse/src/index.ts +2 -0
  89. package/src/libs/feature/notify-reuse/src/lib/edit-delete-reuse-buttons/edit-delete-reuse-buttons.component.css +0 -0
  90. package/src/libs/feature/notify-reuse/src/lib/edit-delete-reuse-buttons/edit-delete-reuse-buttons.component.html +23 -0
  91. package/src/libs/feature/notify-reuse/src/lib/edit-delete-reuse-buttons/edit-delete-reuse-buttons.component.ts +114 -0
  92. package/src/libs/feature/notify-reuse/src/lib/notify-reuse-form/notify-reuse-form.component.css +0 -0
  93. package/src/libs/feature/notify-reuse/src/lib/notify-reuse-form/notify-reuse-form.component.html +111 -0
  94. package/src/libs/feature/notify-reuse/src/lib/notify-reuse-form/notify-reuse-form.component.ts +234 -0
  95. package/src/libs/feature/notify-reuse/src/lib/utils/url.ts +16 -0
  96. package/src/libs/feature/record/src/lib/map-view/map-view.component.ts +9 -1
  97. package/src/libs/feature/search/src/lib/results-table/results-table-container.component.ts +10 -5
  98. package/src/libs/ui/catalog/src/lib/language-switcher/language-switcher.component.ts +2 -2
  99. package/src/libs/ui/dataviz/src/index.ts +0 -1
  100. package/src/libs/ui/elements/src/index.ts +3 -0
  101. package/src/libs/ui/elements/src/lib/contact-details/contact-details.component.html +88 -0
  102. package/src/libs/ui/elements/src/lib/contact-details/contact-details.component.ts +45 -0
  103. package/src/libs/ui/elements/src/lib/contact-pill/contact-pill.component.html +41 -0
  104. package/src/libs/ui/elements/src/lib/contact-pill/contact-pill.component.ts +70 -0
  105. package/src/libs/ui/elements/src/lib/external-link-card/external-link-card.component.html +2 -1
  106. package/src/libs/ui/elements/src/lib/external-link-card/external-link-card.component.ts +2 -2
  107. package/src/libs/ui/elements/src/lib/image-overlay-preview/image-overlay-preview.component.css +13 -0
  108. package/src/libs/ui/elements/src/lib/image-overlay-preview/image-overlay-preview.component.html +13 -3
  109. package/src/libs/ui/elements/src/lib/image-overlay-preview/image-overlay-preview.component.ts +43 -6
  110. package/src/libs/ui/elements/src/lib/internal-link-card/internal-link-card.component.html +1 -1
  111. package/src/libs/ui/elements/src/lib/internal-link-card/internal-link-card.component.ts +0 -1
  112. package/src/libs/ui/elements/src/lib/keyword-badge/keyword-badge.component.html +14 -0
  113. package/src/libs/ui/elements/src/lib/keyword-badge/keyword-badge.component.ts +85 -0
  114. package/src/libs/ui/elements/src/lib/markdown-parser/markdown-parser.component.ts +2 -2
  115. package/src/libs/ui/elements/src/lib/metadata-contact/metadata-contact.component.ts +2 -5
  116. package/src/libs/ui/elements/src/lib/metadata-info/metadata-info.component.css +3 -2
  117. package/src/libs/ui/elements/src/lib/metadata-info/metadata-info.component.html +26 -68
  118. package/src/libs/ui/elements/src/lib/metadata-info/metadata-info.component.ts +30 -10
  119. package/src/libs/ui/elements/src/lib/record-api-form/record-api-form.component.html +4 -5
  120. package/src/libs/ui/elements/src/lib/record-api-form/record-api-form.component.ts +4 -0
  121. package/src/libs/ui/inputs/src/lib/badge/badge.component.html +33 -26
  122. package/src/libs/ui/inputs/src/lib/badge/badge.component.ts +9 -0
  123. package/src/libs/ui/inputs/src/lib/button/button.component.ts +8 -0
  124. package/src/libs/ui/inputs/src/lib/date-picker/date-picker.component.ts +2 -2
  125. package/src/libs/ui/inputs/src/lib/dropdown-multiselect/dropdown-multiselect.component.html +1 -1
  126. package/src/libs/ui/inputs/src/lib/dropdown-multiselect/dropdown-multiselect.component.ts +3 -3
  127. package/src/libs/ui/inputs/src/lib/text-input/text-input.component.css +0 -3
  128. package/src/libs/ui/inputs/src/lib/text-input/text-input.component.html +11 -7
  129. package/src/libs/ui/inputs/src/lib/text-input/text-input.component.ts +56 -30
  130. package/src/libs/ui/inputs/src/lib/url-input/url-input.component.html +2 -2
  131. package/src/libs/ui/inputs/src/lib/url-input/url-input.component.ts +2 -1
  132. package/src/libs/ui/layout/src/lib/form-field-wrapper/form-field-wrapper.component.html +5 -5
  133. package/src/libs/ui/layout/src/lib/form-field-wrapper/form-field-wrapper.component.ts +9 -1
  134. package/src/libs/ui/layout/src/lib/interactive-table/interactive-table.component.html +1 -1
  135. package/src/libs/ui/layout/src/lib/interactive-table/interactive-table.component.ts +3 -8
  136. package/src/libs/ui/layout/src/lib/sortable-list/sortable-list.component.html +7 -7
  137. package/src/libs/ui/layout/src/lib/sortable-list/sortable-list.component.ts +14 -1
  138. package/src/libs/ui/map/src/lib/components/map-container/map-container.component.ts +2 -1
  139. package/src/libs/ui/map/src/lib/components/spatial-extent/spatial-extent.component.ts +15 -64
  140. package/src/libs/ui/map/src/lib/map-utils.ts +48 -0
  141. package/src/libs/ui/search/src/lib/record-preview-feed/record-preview-feed.component.ts +1 -1
  142. package/src/libs/ui/search/src/lib/record-preview-row/record-preview-row.component.html +0 -1
  143. package/src/libs/ui/search/src/lib/results-table/action-menu/action-menu.component.html +12 -8
  144. package/src/libs/ui/search/src/lib/results-table/action-menu/action-menu.component.ts +3 -1
  145. package/src/libs/ui/search/src/lib/results-table/results-table.component.html +1 -0
  146. package/src/libs/ui/widgets/src/lib/popover/popover.component.ts +7 -1
  147. package/src/libs/ui/widgets/src/lib/spinning-loader/spinning-loader.component.html +1 -1
  148. package/src/libs/util/app-config/src/lib/app-config.ts +70 -9
  149. package/src/libs/util/app-config/src/lib/model.ts +7 -0
  150. package/src/libs/util/app-config/src/lib/parse-utils.ts +50 -1
  151. package/src/libs/util/data-fetcher/src/lib/readers/base-file.ts +1 -2
  152. package/src/libs/util/data-fetcher/src/lib/readers/wfs.ts +6 -3
  153. package/src/libs/util/i18n/src/index.ts +0 -1
  154. package/src/libs/util/i18n/src/lib/i18n.constants.ts +17 -11
  155. package/src/libs/util/i18n/src/lib/i18n.interceptor.ts +2 -2
  156. package/src/libs/util/i18n/src/lib/i18n.providers.ts +14 -17
  157. package/src/libs/util/i18n/src/lib/test.translate.loader.ts +48 -0
  158. package/src/libs/util/shared/src/lib/links/link-utils.ts +23 -1
  159. package/src/libs/util/shared/src/lib/record/quality-score.util.ts +33 -18
  160. package/src/libs/util/shared/src/lib/services/date.service.ts +3 -3
  161. package/src/libs/util/shared/src/lib/utils/geojson.ts +58 -1
  162. package/src/libs/util/shared/src/lib/utils/index.ts +1 -0
  163. package/src/libs/util/shared/src/lib/utils/user-display.ts +32 -0
  164. package/style.css +0 -1
  165. package/tailwind.base.css +26 -2
  166. package/translations/de.json +112 -50
  167. package/translations/en.json +117 -55
  168. package/translations/es.json +86 -24
  169. package/translations/fr.json +114 -52
  170. package/translations/it.json +113 -51
  171. package/translations/nl.json +85 -23
  172. package/translations/pt.json +86 -24
  173. package/translations/sk.json +86 -24
  174. package/src/libs/feature/editor/src/lib/components/generic-keywords/generic-keywords.component.css +0 -3
@@ -4,6 +4,7 @@ import {
4
4
  Component,
5
5
  ElementRef,
6
6
  EventEmitter,
7
+ inject,
7
8
  Input,
8
9
  Output,
9
10
  ViewChild,
@@ -17,11 +18,18 @@ import {
17
18
  Individual,
18
19
  Keyword,
19
20
  OnlineResource,
21
+ RecordKind,
20
22
  UpdateFrequency,
21
23
  } from '../../../../../../../../libs/common/domain/src/lib/model/record'
22
24
  import { FormFieldWrapperComponent } from '../../../../../../../../libs/ui/layout/src'
25
+ import { NgIconComponent, provideIcons } from '@ng-icons/core'
26
+ import {
27
+ matEditOutline,
28
+ matHelpOutline,
29
+ } from '@ng-icons/material-icons/outline'
23
30
  import { TranslatePipe } from '@ngx-translate/core'
24
31
  import {
32
+ FieldFocusDirective,
25
33
  FormFieldDateComponent,
26
34
  FormFieldLicenseComponent,
27
35
  FormFieldTemporalExtentsComponent,
@@ -36,6 +44,7 @@ import { FormFieldContactsComponent } from './form-field-contacts/form-field-con
36
44
  import { FormFieldKeywordsComponent } from './form-field-keywords/form-field-keywords.component'
37
45
  import { FormFieldOnlineLinkResourcesComponent } from './form-field-online-link-resources/form-field-online-link-resources.component'
38
46
  import { FormFieldOnlineResourcesComponent } from './form-field-online-resources/form-field-online-resources.component'
47
+ import { FormFieldOnlineSingleLinkResourceComponent } from './form-field-online-single-link-resource/form-field-online-single-link-resource.component'
39
48
  import { FormFieldOverviewsComponent } from './form-field-overviews/form-field-overviews.component'
40
49
  import { FormFieldRichComponent } from './form-field-rich/form-field-rich.component'
41
50
  import { FormFieldSimpleComponent } from './form-field-simple/form-field-simple.component'
@@ -70,16 +79,21 @@ import { FormFieldTopicsComponent } from './form-field-topics/form-field-topics.
70
79
  FormFieldContactsForResourceComponent,
71
80
  FormFieldOnlineResourcesComponent,
72
81
  FormFieldOnlineLinkResourcesComponent,
82
+ FormFieldOnlineSingleLinkResourceComponent,
73
83
  FormFieldContactsComponent,
74
84
  FormFieldConstraintsComponent,
75
85
  FormFieldConstraintsShortcutsComponent,
76
86
  FormFieldSpatialToggleComponent,
77
87
  FormFieldTopicsComponent,
78
88
  TextFieldModule,
89
+ NgIconComponent,
79
90
  ],
91
+ providers: [provideIcons({ matEditOutline, matHelpOutline })],
92
+ hostDirectives: [FieldFocusDirective],
80
93
  })
81
94
  export class FormFieldComponent {
82
95
  @Input() uniqueIdentifier: string
96
+ @Input() recordKind: RecordKind
83
97
  @Input() model: CatalogRecordKeys
84
98
  @Input() modelSpecifier: FieldModelSpecifier
85
99
  @Input() componentName: FormFieldComponentName
@@ -92,6 +106,8 @@ export class FormFieldComponent {
92
106
  @ViewChild('titleInput') titleInput: ElementRef
93
107
  isOpenData = false
94
108
 
109
+ fieldFocus = inject(FieldFocusDirective)
110
+
95
111
  toggleIsOpenData(event: boolean) {
96
112
  this.isOpenData = event
97
113
  }
@@ -1,3 +1,4 @@
1
+ export * from './field-focus.directive'
1
2
  export * from './form-field-keywords/form-field-keywords.component'
2
3
  export * from './form-field-license/form-field-license.component'
3
4
  export * from './form-field-date/form-field-date.component'
@@ -0,0 +1,35 @@
1
+ /* Glow applied by FieldFocusDirective (gnUiFieldFocus) to a focused field or,
2
+ when the field is hidden, to its whole section. */
3
+ .gn-ui-field-focus-glow {
4
+ position: relative;
5
+ }
6
+
7
+ .gn-ui-field-focus-glow::after {
8
+ content: '';
9
+ position: absolute;
10
+ inset: -8px;
11
+ border-radius: 8px;
12
+ background-color: color-mix(in srgb, var(--color-primary) 12%, transparent);
13
+ pointer-events: none;
14
+ animation: gn-ui-field-focus-glow 3s ease-out forwards;
15
+ }
16
+
17
+ .gn-ui-field-focus-glow::before {
18
+ content: '';
19
+ position: absolute;
20
+ inset: -16px;
21
+ border: 2px dashed var(--color-primary);
22
+ border-radius: 10px;
23
+ pointer-events: none;
24
+ animation: gn-ui-field-focus-glow 3s ease-out forwards;
25
+ }
26
+
27
+ @keyframes gn-ui-field-focus-glow {
28
+ 0%,
29
+ 55% {
30
+ opacity: 1;
31
+ }
32
+ 100% {
33
+ opacity: 0;
34
+ }
35
+ }
@@ -3,16 +3,28 @@
3
3
  section of facade.currentSections$ | async;
4
4
  track sectionTracker($index, section)
5
5
  ) {
6
- @if (!section.hidden) {
7
- <div class="flex flex-col gap-6 border p-8 rounded-[8px] shadow">
6
+ @if (evaluateExpression(section.hidden)() !== true) {
7
+ <div
8
+ class="flex flex-col gap-6 border p-8 rounded-[8px] shadow scroll-mt-[90px] bg-white"
9
+ gnUiFieldFocus
10
+ #sectionFocus="fieldFocus"
11
+ >
8
12
  <div class="flex flex-col gap-2">
9
13
  @if (section.labelKey) {
10
- <div class="text-2xl font-title text-black" translate>
14
+ <div
15
+ class="text-2xl font-title text-black"
16
+ translate
17
+ [translateParams]="{ recordKind: recordKind$ | async }"
18
+ >
11
19
  {{ section.labelKey }}
12
20
  </div>
13
21
  }
14
22
  @if (section.descriptionKey) {
15
- <div class="text-gray-800 text-sm" translate>
23
+ <div
24
+ class="text-gray-800 text-sm"
25
+ translate
26
+ [translateParams]="{ recordKind: recordKind$ | async }"
27
+ >
16
28
  {{ section.descriptionKey }}
17
29
  </div>
18
30
  }
@@ -22,7 +34,7 @@
22
34
  field of section.fieldsWithValues;
23
35
  track fieldTracker($index, field)
24
36
  ) {
25
- @if (!field.config.hidden) {
37
+ @if (evaluateExpression(field.config.hidden)() !== true) {
26
38
  <gn-ui-form-field
27
39
  [ngClass]="
28
40
  field.config.gridColumnSpan === 1
@@ -30,6 +42,7 @@
30
42
  : 'col-span-2'
31
43
  "
32
44
  [uniqueIdentifier]="recordUniqueIdentifier$ | async"
45
+ [recordKind]="recordKind$ | async"
33
46
  [model]="field.config.model!"
34
47
  [modelSpecifier]="field.config.modelSpecifier!"
35
48
  [config]="field.config.formFieldConfig"
@@ -1,15 +1,29 @@
1
1
  import { CommonModule } from '@angular/common'
2
- import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
2
+ import {
3
+ ChangeDetectionStrategy,
4
+ Component,
5
+ computed,
6
+ inject,
7
+ OnDestroy,
8
+ OnInit,
9
+ Signal,
10
+ signal,
11
+ viewChildren,
12
+ } from '@angular/core'
3
13
  import { EditorFacade } from '../../+state/editor.facade'
4
14
  import { EditorFieldValue } from '../../models'
5
- import { FormFieldComponent } from './form-field'
15
+ import { FieldFocusDirective, FormFieldComponent } from './form-field'
6
16
  import { TranslateDirective } from '@ngx-translate/core'
7
17
  import {
8
18
  EditorFieldWithValue,
9
19
  EditorSectionWithValues,
10
20
  } from '../../+state/editor.models'
11
- import { map } from 'rxjs'
21
+ import { firstValueFrom, map, Subscription, withLatestFrom } from 'rxjs'
12
22
  import { CatalogRecordKeys } from '../../../../../../../libs/common/domain/src/lib/model/record'
23
+ import { switchMap } from 'rxjs/operators'
24
+ import { toSignal } from '@angular/core/rxjs-interop'
25
+ import { evaluate, isExpression } from '../../expressions'
26
+ import { NotificationsService } from '../../../../../../../libs/feature/notifications/src'
13
27
 
14
28
  @Component({
15
29
  selector: 'gn-ui-record-form',
@@ -17,14 +31,64 @@ import { CatalogRecordKeys } from '../../../../../../../libs/common/domain/src/l
17
31
  styleUrls: ['./record-form.component.css'],
18
32
  changeDetection: ChangeDetectionStrategy.OnPush,
19
33
  standalone: true,
20
- imports: [CommonModule, FormFieldComponent, TranslateDirective],
34
+ imports: [
35
+ CommonModule,
36
+ FormFieldComponent,
37
+ FieldFocusDirective,
38
+ TranslateDirective,
39
+ ],
21
40
  })
22
- export class RecordFormComponent {
41
+ export class RecordFormComponent implements OnInit, OnDestroy {
23
42
  facade = inject(EditorFacade)
43
+ notifications = inject(NotificationsService)
44
+ subscription = new Subscription()
24
45
 
25
46
  recordUniqueIdentifier$ = this.facade.record$.pipe(
26
47
  map((record) => record.uniqueIdentifier)
27
48
  )
49
+ recordKind$ = this.facade.record$.pipe(map((record) => record.kind))
50
+
51
+ focusFieldWithPage$ = this.facade.focusedField$.pipe(
52
+ switchMap(async (field) => {
53
+ const location = await this.getFieldLocation(field)
54
+ return [field, location?.page ?? null, location?.section ?? -1] as const
55
+ })
56
+ )
57
+
58
+ formFields = viewChildren(FormFieldComponent)
59
+ sectionFocusDirectives = viewChildren<FieldFocusDirective>('sectionFocus')
60
+
61
+ focusField(model: CatalogRecordKeys, sectionIndex: number) {
62
+ const field = this.formFields().find((f) => f.model === model)
63
+ if (field) {
64
+ field.fieldFocus.focusField()
65
+ return
66
+ }
67
+ this.sectionFocusDirectives()[sectionIndex]?.focusField(false)
68
+ }
69
+
70
+ ngOnInit() {
71
+ this.subscription.add(
72
+ this.focusFieldWithPage$
73
+ .pipe(
74
+ withLatestFrom(
75
+ this.facade.currentPage$,
76
+ ([field, fieldPage, fieldSection], currentPage) =>
77
+ [field, fieldPage, fieldSection, currentPage] as const
78
+ )
79
+ )
80
+ .subscribe(([field, fieldPage, fieldSection, currentPage]) => {
81
+ if (fieldPage !== null && fieldPage !== currentPage) {
82
+ this.facade.setCurrentPage(fieldPage)
83
+ }
84
+ setTimeout(() => this.focusField(field, fieldSection))
85
+ })
86
+ )
87
+ }
88
+
89
+ ngOnDestroy() {
90
+ this.subscription.unsubscribe()
91
+ }
28
92
 
29
93
  handleFieldValueChange(model: CatalogRecordKeys, newValue: EditorFieldValue) {
30
94
  if (!model) {
@@ -40,4 +104,53 @@ export class RecordFormComponent {
40
104
  sectionTracker(index: number, section: EditorSectionWithValues) {
41
105
  return section.labelKey
42
106
  }
107
+
108
+ async getFieldLocation(
109
+ model: CatalogRecordKeys
110
+ ): Promise<{ page: number; section: number } | null> {
111
+ const config = await firstValueFrom(this.facade.editorConfig$)
112
+ const page = config.pages.findIndex((p) =>
113
+ p.sections.some((s) => s.fields.some((f) => f.model === model))
114
+ )
115
+ if (page < 0) {
116
+ return null
117
+ }
118
+ const section = config.pages[page].sections
119
+ .filter((s) => !s.hidden)
120
+ .findIndex((s) => s.fields.some((f) => f.model === model))
121
+ return { page, section }
122
+ }
123
+
124
+ private recordSignal = toSignal(this.facade.record$, { requireSync: true })
125
+ private expressionCache = new Map<string, Signal<EditorFieldValue>>()
126
+
127
+ // only compiles the expression into an evaluator the first time;
128
+ // then we store a computed() signal in the cache so that the evaluator is
129
+ // run again if the record changes
130
+ evaluateExpression(
131
+ expressionOrValue: string | EditorFieldValue
132
+ ): Signal<EditorFieldValue> {
133
+ if (!isExpression(expressionOrValue)) {
134
+ return signal(expressionOrValue as EditorFieldValue)
135
+ }
136
+ const expression = expressionOrValue as string
137
+ if (this.expressionCache.has(expression)) {
138
+ return this.expressionCache.get(expression)
139
+ }
140
+ const { evaluator, errors } = evaluate(expression)
141
+ if (errors.length) {
142
+ console.error(`The following errors happened while evaluating the expression '${expression}':
143
+ ${errors.map((err) => `> ${err}`).join('\n')}`)
144
+ this.notifications.showNotification({
145
+ type: 'warning',
146
+ title: 'Technical error',
147
+ text: 'An error happened while evaluating an expression inside the editor configuration; open the developer console for more information',
148
+ })
149
+ }
150
+ this.expressionCache.set(
151
+ expression,
152
+ computed(() => evaluator({ globals: { record: this.recordSignal() } }))
153
+ )
154
+ return this.expressionCache.get(expression)
155
+ }
43
156
  }
@@ -1,16 +1,388 @@
1
- import { EditorFieldState, EditorFieldValue } from './models/'
1
+ import { EditorFieldValue } from './models/'
2
2
 
3
- export type ExpressionEvaluator = (field: EditorFieldState) => EditorFieldValue
3
+ export interface EvaluationContext {
4
+ globals: Record<string, object>
5
+ }
6
+
7
+ export type ExpressionEvaluator = (
8
+ context: EvaluationContext
9
+ ) => EditorFieldValue
10
+
11
+ const TOKEN_FUNCTION_CALL_START = '('
12
+ const TOKEN_FUNCTION_CALL_END = ')'
13
+ const TOKEN_PROPERTY_ACCESS = '.'
14
+ const TOKEN_OPERATOR_TERNARY_FIRST = '?'
15
+ const TOKEN_OPERATOR_TERNARY_SECOND = ':'
16
+ const TOKEN_OPERATOR_LOGICAL = ['==', '!=']
17
+ const TOKEN_STRING_LITERAL = ["'", '"']
18
+ const TOKEN_GROUP_START = '('
19
+ const TOKEN_GROUP_END = ')'
20
+
21
+ // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_precedence
22
+ const OPERATORS_BY_PRECEDENCE: string[][] = [
23
+ [TOKEN_OPERATOR_TERNARY_FIRST, TOKEN_OPERATOR_TERNARY_SECOND],
24
+ TOKEN_OPERATOR_LOGICAL,
25
+ ]
26
+ const ALL_OPERATORS = OPERATORS_BY_PRECEDENCE.flat()
27
+
28
+ export interface CompilationContext {
29
+ errors: string[]
30
+ }
31
+
32
+ export function isExpression(input: unknown): boolean {
33
+ return typeof input === 'string' && /^\${.*}$/.test(input)
34
+ }
35
+
36
+ function readNext(input: string, start: number, length = 1): string {
37
+ return input.substring(start, start + length)
38
+ }
4
39
 
5
- export function evaluate(expression: string): ExpressionEvaluator {
6
- if (expression.match(/^\${.*}$/)) {
7
- return evaluate(expression.slice(2, -1))
40
+ /**
41
+ * Will continue looking until an operator is found at the same depth level
42
+ */
43
+ function readUntilNextOperator(
44
+ input: string,
45
+ start: number,
46
+ currentOperator: string
47
+ ): string {
48
+ let pos = start
49
+ let result = ''
50
+ let depth = 0
51
+ while (pos < input.length) {
52
+ const isOperator = ALL_OPERATORS.some(
53
+ (token) => readNext(input, pos, token.length) === token
54
+ )
55
+ if (isOperator && depth === 0) {
56
+ const operatorPrecedence = OPERATORS_BY_PRECEDENCE.findIndex((tokens) =>
57
+ tokens.some((token) => readNext(input, pos, token.length) === token)
58
+ )
59
+ const currentPrecedence =
60
+ OPERATORS_BY_PRECEDENCE.findIndex((tokens) =>
61
+ tokens.some((token) => currentOperator === token)
62
+ ) ?? -1
63
+ // the new operator has a lower precedence than the current one: we stop here
64
+ if (operatorPrecedence < currentPrecedence) {
65
+ return result
66
+ }
67
+ }
68
+ if (input[pos] === TOKEN_GROUP_START) {
69
+ depth++
70
+ } else if (input[pos] === TOKEN_GROUP_END) {
71
+ depth--
72
+ }
73
+ if (depth < 0) {
74
+ return result
75
+ }
76
+ result += input[pos]
77
+ pos++
8
78
  }
9
- const operator = expression.split('(')[0]
10
- switch (operator) {
79
+ return result
80
+ }
81
+
82
+ /**
83
+ * Will look for a specific character or string situated at the same depth level!
84
+ * Returns null if nothing found
85
+ */
86
+ function readUntil(input: string, start: number, char: string): string | null {
87
+ let pos = start
88
+ let result = ''
89
+ let depth = 0
90
+ while (pos < input.length) {
91
+ if (depth === 0 && readNext(input, pos, char.length) === char) {
92
+ return result
93
+ }
94
+ if (input[pos] === TOKEN_GROUP_START) {
95
+ depth++
96
+ } else if (input[pos] === TOKEN_GROUP_END) {
97
+ depth--
98
+ }
99
+ if (depth < 0) {
100
+ return null
101
+ }
102
+ result += input[pos]
103
+ pos++
104
+ }
105
+ return null
106
+ }
107
+
108
+ function getFunctionEvaluator(
109
+ functionName: string,
110
+ context: CompilationContext
111
+ ): ExpressionEvaluator {
112
+ switch (functionName) {
11
113
  case 'dateNow':
12
114
  return () => new Date()
13
115
  default:
14
- throw new Error(`Unknown operator: ${operator}`)
116
+ context.errors.push(`Unknown function: ${functionName}`)
117
+ return () => undefined
118
+ }
119
+ }
120
+
121
+ /**
122
+ * This evaluates an expression using a very light subset of Javascript.
123
+ *
124
+ * Things allowed in expressions are:
125
+ * - comparing values with == and !=
126
+ * - ternary comparisons with a ? b : c
127
+ * - grouping with (...)
128
+ * - functions: for now only dateNow() is supported and returns a timestamp
129
+ * - access to globals such as record.type
130
+ *
131
+ * Expressions are turned into an evaluator that can be called with a context containing the globals.
132
+ * Expressions can return any primitive Javascript type
133
+ * Valid expressions should always be enclosed in ${...}
134
+ */
135
+ export function evaluate(expression: string): {
136
+ evaluator: ExpressionEvaluator
137
+ errors: string[]
138
+ } {
139
+ if (!/^\${.*}$/.test(expression)) {
140
+ throw new Error('Malformed expression: missing ${} enclosure')
141
+ }
142
+ const context = {
143
+ errors: [],
144
+ }
145
+ const trimmedExpression = expression.slice(2, -1)
146
+ return evaluateInternal(trimmedExpression, context)
147
+ }
148
+
149
+ function evaluateInternal(
150
+ expression: string,
151
+ currentContext: CompilationContext
152
+ ): { evaluator: ExpressionEvaluator; errors: string[] } {
153
+ let pos = 0
154
+ let currentSymbol = null
155
+ let currentEvaluator: ExpressionEvaluator = () => undefined
156
+
157
+ while (pos < expression.length) {
158
+ const isWhitespace = /\s/.test(readNext(expression, pos))
159
+ if (isWhitespace) {
160
+ pos++
161
+ continue
162
+ }
163
+
164
+ // SYMBOL (NAME)
165
+ const isSymbol = /[a-zA-Z]/.test(readNext(expression, pos))
166
+ if (isSymbol) {
167
+ currentSymbol = ''
168
+ while (pos < expression.length) {
169
+ if (!/[a-zA-Z]/.test(readNext(expression, pos))) {
170
+ break
171
+ }
172
+ currentSymbol += readNext(expression, pos)
173
+ pos++
174
+ }
175
+ continue
176
+ }
177
+
178
+ // FUNCTION CALL
179
+ const isFunctionCall =
180
+ currentSymbol && readNext(expression, pos) === TOKEN_FUNCTION_CALL_START
181
+ if (isFunctionCall) {
182
+ pos++
183
+ // no support for arguments for now (not needed)
184
+ if (readNext(expression, pos) !== TOKEN_FUNCTION_CALL_END) {
185
+ currentContext.errors.push(
186
+ `Expected token '${TOKEN_FUNCTION_CALL_END}' at position ${pos}, got '${readNext(expression, pos)}' instead`
187
+ )
188
+ break
189
+ }
190
+ pos++
191
+ currentEvaluator = getFunctionEvaluator(currentSymbol, currentContext)
192
+ currentSymbol = null
193
+ continue
194
+ }
195
+
196
+ // PROPERTY ACCESS (single level)
197
+ const isPropertyAccess =
198
+ currentSymbol && readNext(expression, pos) === TOKEN_PROPERTY_ACCESS
199
+ if (isPropertyAccess) {
200
+ pos++
201
+ if (!/[a-zA-Z]/.test(readNext(expression, pos))) {
202
+ currentContext.errors.push(
203
+ `Expected a property name after '${TOKEN_PROPERTY_ACCESS}' at position ${pos}, got '${readNext(expression, pos)}' instead`
204
+ )
205
+ break
206
+ }
207
+ let propertyName = ''
208
+ while (pos < expression.length) {
209
+ if (!/[a-zA-Z]/.test(readNext(expression, pos))) {
210
+ break
211
+ }
212
+ propertyName += readNext(expression, pos)
213
+ pos++
214
+ }
215
+ const variableName = currentSymbol
216
+ currentEvaluator = (context) => {
217
+ if (
218
+ !Object.prototype.hasOwnProperty.call(context.globals, variableName)
219
+ ) {
220
+ return undefined
221
+ }
222
+ if (
223
+ !Object.prototype.hasOwnProperty.call(
224
+ context.globals[variableName],
225
+ propertyName
226
+ )
227
+ ) {
228
+ return undefined
229
+ }
230
+ return context.globals[variableName][propertyName]
231
+ }
232
+ currentSymbol = null
233
+ continue
234
+ }
235
+
236
+ // we have a symbol but did not do either function call or property access => error
237
+ if (currentSymbol) {
238
+ currentContext.errors.push(
239
+ `Expected a function call or property access after symbol '${currentSymbol}' at position ${pos}, got '${readNext(expression, pos)}' instead`
240
+ )
241
+ break
242
+ }
243
+
244
+ // GROUP OPERATOR
245
+ const isGroupStart = readNext(expression, pos) === TOKEN_GROUP_START
246
+ if (isGroupStart) {
247
+ pos++
248
+ const groupExpression = readUntil(expression, pos, TOKEN_GROUP_END)
249
+ if (groupExpression === null) {
250
+ currentContext.errors.push(
251
+ `Reached end of expression before finding expected token '${TOKEN_GROUP_END}'`
252
+ )
253
+ break
254
+ }
255
+ pos += groupExpression.length + 1
256
+ currentEvaluator = evaluateInternal(
257
+ groupExpression,
258
+ currentContext
259
+ ).evaluator
260
+ continue
261
+ }
262
+
263
+ // LOGICAL OPERATORS
264
+ const isLogicalOperator = TOKEN_OPERATOR_LOGICAL.some(
265
+ (token) => readNext(expression, pos, token.length) === token
266
+ )
267
+ if (isLogicalOperator) {
268
+ const matchingOperator = TOKEN_OPERATOR_LOGICAL.find(
269
+ (token) => readNext(expression, pos, token.length) === token
270
+ )
271
+ const isEqual = matchingOperator.startsWith('=')
272
+ pos += matchingOperator.length
273
+ const comparedEvaluator = currentEvaluator
274
+ const comparedToExpression = readUntilNextOperator(
275
+ expression,
276
+ pos,
277
+ matchingOperator
278
+ )
279
+ pos += comparedToExpression.length
280
+ const comparedToEvaluator = evaluateInternal(
281
+ comparedToExpression,
282
+ currentContext
283
+ ).evaluator
284
+
285
+ currentEvaluator = (context) => {
286
+ return (
287
+ (comparedEvaluator(context) == comparedToEvaluator(context)) ===
288
+ isEqual
289
+ )
290
+ }
291
+ continue
292
+ }
293
+
294
+ // TERNARY OPERATOR
295
+ const isTernary = readNext(expression, pos) === TOKEN_OPERATOR_TERNARY_FIRST
296
+ if (isTernary) {
297
+ if (!currentEvaluator) {
298
+ currentContext.errors.push(
299
+ `Expected an expression before '${TOKEN_OPERATOR_TERNARY_FIRST}' at position ${pos} but could not find any`
300
+ )
301
+ break
302
+ }
303
+ pos++
304
+ const ifTrueExpression = readUntil(
305
+ expression,
306
+ pos,
307
+ TOKEN_OPERATOR_TERNARY_SECOND
308
+ )
309
+ if (ifTrueExpression === null) {
310
+ currentContext.errors.push(
311
+ `Reached end of expression before finding expected token '${TOKEN_OPERATOR_TERNARY_SECOND}'`
312
+ )
313
+ break
314
+ }
315
+ pos += ifTrueExpression.length + 1
316
+
317
+ const ifFalseExpression = readUntilNextOperator(
318
+ expression,
319
+ pos,
320
+ TOKEN_OPERATOR_TERNARY_FIRST
321
+ )
322
+ pos += ifFalseExpression.length
323
+
324
+ const conditionEvaluator = currentEvaluator
325
+ const ifTrueEvaluator = evaluateInternal(
326
+ ifTrueExpression,
327
+ currentContext
328
+ ).evaluator
329
+ const ifFalseEvaluator = evaluateInternal(
330
+ ifFalseExpression,
331
+ currentContext
332
+ ).evaluator
333
+ currentEvaluator = (context) => {
334
+ if (conditionEvaluator(context)) {
335
+ return ifTrueEvaluator(context)
336
+ }
337
+ return ifFalseEvaluator(context)
338
+ }
339
+ continue
340
+ }
341
+
342
+ // STRING LITERAL
343
+ const isStringLiteral = TOKEN_STRING_LITERAL.some(
344
+ (token) => readNext(expression, pos) === token
345
+ )
346
+ if (isStringLiteral) {
347
+ const openingToken = readNext(expression, pos)
348
+ pos++
349
+ const literal = readUntil(expression, pos, openingToken)
350
+ if (literal === null) {
351
+ currentContext.errors.push(
352
+ `Reached end of expression before finding corresponding closing token ${openingToken} for string literal`
353
+ )
354
+ break
355
+ }
356
+ pos += literal.length + 1
357
+ currentEvaluator = () => literal
358
+ continue
359
+ }
360
+
361
+ // NUMBER LITERAL
362
+ const isNumberLiteral = /[0-9]/.test(readNext(expression, pos))
363
+ if (isNumberLiteral) {
364
+ let literal = ''
365
+ while (pos < expression.length) {
366
+ if (!/[0-9]/.test(readNext(expression, pos))) {
367
+ break
368
+ }
369
+ literal += readNext(expression, pos)
370
+ pos++
371
+ }
372
+ const numberLiteral = parseInt(literal, 10)
373
+ currentEvaluator = () => numberLiteral
374
+ continue
375
+ }
376
+
377
+ // could not figure out what that was: leave
378
+ currentContext.errors.push(
379
+ `Unexpected character '${readNext(expression, pos)}' at position ${pos}`
380
+ )
381
+ break
382
+ }
383
+
384
+ return {
385
+ evaluator: currentEvaluator,
386
+ errors: currentContext.errors,
15
387
  }
16
388
  }