diff --git a/build/Dockerfile b/build/Dockerfile index 1e11e6fee3..b371ecb006 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -70,7 +70,7 @@ RUN if [ "$SENTRY_AUTH_TOKEN" != "" ] ; then \ ### PROD image -FROM nginx:1.25.4-alpine +FROM nginx:1.25.5-alpine COPY ./build/default.conf /etc/nginx/templates/default.conf COPY --from=builder /app/dist/ /usr/share/nginx/html # The port on which the app will run in the Docker container diff --git a/doc/compodoc_sources/how-to-guides/create-custom-view.md b/doc/compodoc_sources/how-to-guides/create-custom-view.md new file mode 100644 index 0000000000..51ccb5214d --- /dev/null +++ b/doc/compodoc_sources/how-to-guides/create-custom-view.md @@ -0,0 +1,64 @@ +# How to create a custom View Component +We aim to build flexible, reusable components. +If you implement a custom component using the building blocks of Aam Digital's platform, this can seamlessly be displayed both in modal forms and as a fullscreen view. + +## Architecture & Generic wrapper components +The following architecture allows you to implement components that only have `@Input` properties +and do not access either Route or Dialog data directly. +Instead, the platform always uses `RoutedViewComponent` or `DialogViewComponent` to parse such context and pass it into your component as simple Angular @Inputs. + +![](../../images/routed-views.png) + +If you implement a special view to display a single entities' details, you should also extend `AbstractEntityDetailsComponent` with your component. +This takes care of loading the entity from the database, in case it is passed in as an id from the URL. + +## Implementing a custom view Component + +1. Create a new component class +2. Add any `@Input()` properties for values that are provided from the config. +3. For EntityDetails views, you get access to an `@Input() entity` and `@Input() entityConstructor` via the `AbstractEntityDetailsComponent` automatically. Otherwise, you do not have to extend from this. +4. Use `` and `` in your template to wrap the elements (if any) that you want to display as a header and action buttons. +These parts are automatically placed differently in the layout depending on whether your component is display as a fullscreen, routed view (actions displayed top right) or as a dialog/modal (actions displayed fixed at bottom). +5. Register your component under a name (string) with the `ComponentRegistry` (usually we do this in one of the modules), so that it can be referenced under this string form the config. +6. You can then use it in config, as shown below. + +Example template for a custom view component: +```html + + + My Entity {{ entity.name }} + + + +
+ My Custom View Content +
+ + + + + +``` + +An example config for the above: +```json +{ + "component": "MyView", + "config": { "showDescription": true } +} +``` + +Use the `ComponentRegistry` to register your component, +e.g. in its Module: +```javascript +export class MyModule { + constructor(components: ComponentRegistry) { + components.addAll([ + [ + "MyView", // this is the name to use in the config document + () => import("./my-view/my-view.component").then((c) => c.MyViewComponent), + ], + ]); + } +} +``` diff --git a/doc/compodoc_sources/how-to-guides/create-entity-details-panel.md b/doc/compodoc_sources/how-to-guides/create-entity-details-panel.md index c288856047..0868d4ce13 100644 --- a/doc/compodoc_sources/how-to-guides/create-entity-details-panel.md +++ b/doc/compodoc_sources/how-to-guides/create-entity-details-panel.md @@ -21,6 +21,7 @@ Those background details aside, what that means for your implementation is: (e.g. `@Input() showDescription: boolean;`, which you can use in your template or code to adapt the component.) These values are automatically set to whatever value is specified in the config object for your component at runtime in the database. 4. Register the new component in its parent module, so that it can be loaded under its name through the config. + (for details see [Create a custom View Component](./create-a-custom-view-component.html)) An example config for the above: ```json @@ -29,18 +30,3 @@ An example config for the above: "config": { "showDescription": true } } ``` - -Use the `ComponentRegistry` to register your component, -e.g. in its Module: -```javascript -export class MyModule { - constructor(components: ComponentRegistry) { - components.addAll([ - [ - "MySubView", // this is the name to use in the config document - () => import("./my-sub-view/my-sub-view.component").then((c) => c.MySubViewComponent), - ], - ]); - } -} -``` diff --git a/doc/compodoc_sources/summary.json b/doc/compodoc_sources/summary.json index 244192ddaa..52b0cf86d3 100644 --- a/doc/compodoc_sources/summary.json +++ b/doc/compodoc_sources/summary.json @@ -119,6 +119,10 @@ "title": "Create a New Entity Type", "file": "how-to-guides/create-new-entity-type.md" }, + { + "title": "Create a custom View Component", + "file": "how-to-guides/create-custom-view.md" + }, { "title": "Create an Entity Details Panel", "file": "how-to-guides/create-entity-details-panel.md" diff --git a/doc/images/routed-views.png b/doc/images/routed-views.png new file mode 100644 index 0000000000..37beeea660 Binary files /dev/null and b/doc/images/routed-views.png differ diff --git a/package-lock.json b/package-lock.json index a2aecd3f4e..c046221a7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,11 +27,11 @@ "@casl/angular": "^8.2.3", "@faker-js/faker": "^8.4.0", "@fortawesome/angular-fontawesome": "^0.14.1", - "@fortawesome/fontawesome-svg-core": "^6.5.1", - "@fortawesome/free-regular-svg-icons": "^6.5.1", - "@fortawesome/free-solid-svg-icons": "^6.5.1", + "@fortawesome/fontawesome-svg-core": "^6.5.2", + "@fortawesome/free-regular-svg-icons": "^6.5.2", + "@fortawesome/free-solid-svg-icons": "^6.5.2", "@ngneat/until-destroy": "^10.0.0", - "@sentry/browser": "^7.102.0", + "@sentry/browser": "^7.108.0", "angulartics2": "^12.2.1", "assert": "^2.1.0", "crypto-es": "^2.1.0", @@ -39,7 +39,7 @@ "hammerjs": "^2.0.8", "json-query": "^2.2.2", "keycloak-angular": "^15.1.0", - "keycloak-js": "^24.0.1", + "keycloak-js": "^24.0.2", "leaflet": "^1.9.4", "lodash-es": "^4.17.21", "md5": "^2.3.0", @@ -4072,45 +4072,45 @@ } }, "node_modules/@fortawesome/fontawesome-common-types": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.1.tgz", - "integrity": "sha512-GkWzv+L6d2bI5f/Vk6ikJ9xtl7dfXtoRu3YGE6nq0p/FFqA1ebMOAWg3XgRyb0I6LYyYkiAo+3/KrwuBp8xG7A==", + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.2.tgz", + "integrity": "sha512-gBxPg3aVO6J0kpfHNILc+NMhXnqHumFxOmjYCFfOiLZfwhnnfhtsdA2hfJlDnj+8PjAs6kKQPenOTKj3Rf7zHw==", "hasInstallScript": true, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/fontawesome-svg-core": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.1.tgz", - "integrity": "sha512-MfRCYlQPXoLlpem+egxjfkEuP9UQswTrlCOsknus/NcMoblTH2g0jPrapbcIb04KGA7E2GZxbAccGZfWoYgsrQ==", + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.2.tgz", + "integrity": "sha512-5CdaCBGl8Rh9ohNdxeeTMxIj8oc3KNBgIeLMvJosBMdslK/UnEB8rzyDRrbKdL1kDweqBPo4GT9wvnakHWucZw==", "hasInstallScript": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "6.5.1" + "@fortawesome/fontawesome-common-types": "6.5.2" }, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/free-regular-svg-icons": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.5.1.tgz", - "integrity": "sha512-m6ShXn+wvqEU69wSP84coxLbNl7sGVZb+Ca+XZq6k30SzuP3X4TfPqtycgUh9ASwlNh5OfQCd8pDIWxl+O+LlQ==", + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.5.2.tgz", + "integrity": "sha512-iabw/f5f8Uy2nTRtJ13XZTS1O5+t+anvlamJ3zJGLEVE2pKsAWhPv2lq01uQlfgCX7VaveT3EVs515cCN9jRbw==", "hasInstallScript": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "6.5.1" + "@fortawesome/fontawesome-common-types": "6.5.2" }, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/free-solid-svg-icons": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.5.1.tgz", - "integrity": "sha512-S1PPfU3mIJa59biTtXJz1oI0+KAXW6bkAb31XKhxdxtuXDiUIFsih4JR1v5BbxY7hVHsD1RKq+jRkVRaf773NQ==", + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.5.2.tgz", + "integrity": "sha512-QWFZYXFE7O1Gr1dTIp+D6UcFUF0qElOnZptpi7PBUMylJh+vFmIedVe1Ir6RM1t2tEQLLSV1k7bR4o92M+uqlw==", "hasInstallScript": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "6.5.1" + "@fortawesome/fontawesome-common-types": "6.5.2" }, "engines": { "node": ">=6" @@ -6940,102 +6940,102 @@ } }, "node_modules/@sentry-internal/feedback": { - "version": "7.107.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-7.107.0.tgz", - "integrity": "sha512-okF0B9AJHrpkwNMxNs/Lffw3N5ZNbGwz4uvCfyOfnMxc7E2VfDM18QzUvTBRvNr3bA9wl+InJ+EMG3aZhyPunA==", + "version": "7.108.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-7.108.0.tgz", + "integrity": "sha512-8JcgZEnk1uWrXJhsd3iRvFtEiVeaWOEhN0NZwhwQXHfvODqep6JtrkY1yCIyxbpA37aZmrPc2JhyotRERGfUjg==", "dependencies": { - "@sentry/core": "7.107.0", - "@sentry/types": "7.107.0", - "@sentry/utils": "7.107.0" + "@sentry/core": "7.108.0", + "@sentry/types": "7.108.0", + "@sentry/utils": "7.108.0" }, "engines": { "node": ">=12" } }, "node_modules/@sentry-internal/replay-canvas": { - "version": "7.107.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-7.107.0.tgz", - "integrity": "sha512-dmDL9g3QDfo7axBOsVnpiKdJ/DXrdeuRv1AqsLgwzJKvItsv0ZizX0u+rj5b1UoxcwbXRMxJ0hit5a1yt3t/ow==", + "version": "7.108.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-7.108.0.tgz", + "integrity": "sha512-R5tvjGqWUV5vSk0N1eBgVW7wIADinrkfDEBZ9FyKP2mXHBobsyNGt30heJDEqYmVqluRqjU2NuIRapsnnrpGnA==", "dependencies": { - "@sentry/core": "7.107.0", - "@sentry/replay": "7.107.0", - "@sentry/types": "7.107.0", - "@sentry/utils": "7.107.0" + "@sentry/core": "7.108.0", + "@sentry/replay": "7.108.0", + "@sentry/types": "7.108.0", + "@sentry/utils": "7.108.0" }, "engines": { "node": ">=12" } }, "node_modules/@sentry-internal/tracing": { - "version": "7.107.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.107.0.tgz", - "integrity": "sha512-le9wM8+OHBbq7m/8P7JUJ1UhSPIty+Z/HmRXc5Z64ODZcOwFV6TmDpYx729IXDdz36XUKmeI+BeM7yQdTTZPfQ==", + "version": "7.108.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/tracing/-/tracing-7.108.0.tgz", + "integrity": "sha512-zuK5XsTsb+U+hgn3SPetYDAogrXsM16U/LLoMW7+TlC6UjlHGYQvmX3o+M2vntejoU1QZS8m1bCAZSMWEypAEw==", "dependencies": { - "@sentry/core": "7.107.0", - "@sentry/types": "7.107.0", - "@sentry/utils": "7.107.0" + "@sentry/core": "7.108.0", + "@sentry/types": "7.108.0", + "@sentry/utils": "7.108.0" }, "engines": { "node": ">=8" } }, "node_modules/@sentry/browser": { - "version": "7.107.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-7.107.0.tgz", - "integrity": "sha512-KnqaQDhxv6w9dJ+mYLsNwPeGZfgbpM3vaismBNyJCKLgWn2V75kxkSq+bDX8LQT/13AyK7iFp317L6P8EuNa3g==", + "version": "7.108.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-7.108.0.tgz", + "integrity": "sha512-FNpzsdTvGvdHJMUelqEouUXMZU7jC+dpN7CdT6IoHVVFEkoAgrjMVUhXZoQ/dmCkdKWHmFSQhJ8Fm6V+e9Aq0A==", "dependencies": { - "@sentry-internal/feedback": "7.107.0", - "@sentry-internal/replay-canvas": "7.107.0", - "@sentry-internal/tracing": "7.107.0", - "@sentry/core": "7.107.0", - "@sentry/replay": "7.107.0", - "@sentry/types": "7.107.0", - "@sentry/utils": "7.107.0" + "@sentry-internal/feedback": "7.108.0", + "@sentry-internal/replay-canvas": "7.108.0", + "@sentry-internal/tracing": "7.108.0", + "@sentry/core": "7.108.0", + "@sentry/replay": "7.108.0", + "@sentry/types": "7.108.0", + "@sentry/utils": "7.108.0" }, "engines": { "node": ">=8" } }, "node_modules/@sentry/core": { - "version": "7.107.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.107.0.tgz", - "integrity": "sha512-C7ogye6+KPyBi8NVL0P8Rxx3Ur7Td8ufnjxosVy678lqY+dcYPk/HONROrzUFYW5fMKWL4/KYnwP+x9uHnkDmw==", + "version": "7.108.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-7.108.0.tgz", + "integrity": "sha512-I/VNZCFgLASxHZaD0EtxZRM34WG9w2gozqgrKGNMzAymwmQ3K9g/1qmBy4e6iS3YRptb7J5UhQkZQHrcwBbjWQ==", "dependencies": { - "@sentry/types": "7.107.0", - "@sentry/utils": "7.107.0" + "@sentry/types": "7.108.0", + "@sentry/utils": "7.108.0" }, "engines": { "node": ">=8" } }, "node_modules/@sentry/replay": { - "version": "7.107.0", - "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-7.107.0.tgz", - "integrity": "sha512-BNJDEVaEwr/YnV22qnyVA1almx/3p615m3+KaF8lPo7YleYgJGSJv1auH64j1G8INkrJ0J0wFBujb1EFjMYkxA==", + "version": "7.108.0", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-7.108.0.tgz", + "integrity": "sha512-jo8fDOzcZJclP1+4n9jUtVxTlBFT9hXwxhAMrhrt70FV/nfmCtYQMD3bzIj79nwbhUtFP6pN39JH1o7Xqt1hxQ==", "dependencies": { - "@sentry-internal/tracing": "7.107.0", - "@sentry/core": "7.107.0", - "@sentry/types": "7.107.0", - "@sentry/utils": "7.107.0" + "@sentry-internal/tracing": "7.108.0", + "@sentry/core": "7.108.0", + "@sentry/types": "7.108.0", + "@sentry/utils": "7.108.0" }, "engines": { "node": ">=12" } }, "node_modules/@sentry/types": { - "version": "7.107.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.107.0.tgz", - "integrity": "sha512-H7qcPjPSUWHE/Zf5bR1EE24G0pGVuJgrSx8Tvvl5nKEepswMYlbXHRVSDN0gTk/E5Z7cqf+hUBOpkQgZyps77w==", + "version": "7.108.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-7.108.0.tgz", + "integrity": "sha512-bKtHITmBN3kqtqE5eVvL8mY8znM05vEodENwRpcm6TSrrBjC2RnwNWVwGstYDdHpNfFuKwC8mLY9bgMJcENo8g==", "engines": { "node": ">=8" } }, "node_modules/@sentry/utils": { - "version": "7.107.0", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.107.0.tgz", - "integrity": "sha512-C6PbN5gHh73MRHohnReeQ60N8rrLYa9LciHue3Ru2290eSThg4CzsPnx4SzkGpkSeVlhhptKtKZ+hp/ha3iVuw==", + "version": "7.108.0", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-7.108.0.tgz", + "integrity": "sha512-a45yEFD5qtgZaIFRAcFkG8C8lnDzn6t4LfLXuV4OafGAy/3ZAN3XN8wDnrruHkiUezSSANGsLg3bXaLW/JLvJw==", "dependencies": { - "@sentry/types": "7.107.0" + "@sentry/types": "7.108.0" }, "engines": { "node": ">=8" @@ -19269,9 +19269,9 @@ } }, "node_modules/keycloak-js": { - "version": "24.0.1", - "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-24.0.1.tgz", - "integrity": "sha512-leV4mlpa0dqYUXTAuq1ufUfk8DOSBCembjQwMwzYrM6xfHSKpcZMxviTWXqro52LMSsYAnivSKVNEvBkLzi7Eg==", + "version": "24.0.2", + "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-24.0.2.tgz", + "integrity": "sha512-V2N8cSz3NfON98XHp+DCzvrb1WW35JalL5Zphe/uoVWOxcof7v522Yz9Q2O3BqXqXP3V/H9ml6o24BwwtXUTGA==", "dependencies": { "js-sha256": "^0.11.0", "jwt-decode": "^4.0.0" diff --git a/package.json b/package.json index ed63af5b43..8a48d71f17 100644 --- a/package.json +++ b/package.json @@ -38,11 +38,11 @@ "@casl/angular": "^8.2.3", "@faker-js/faker": "^8.4.0", "@fortawesome/angular-fontawesome": "^0.14.1", - "@fortawesome/fontawesome-svg-core": "^6.5.1", - "@fortawesome/free-regular-svg-icons": "^6.5.1", - "@fortawesome/free-solid-svg-icons": "^6.5.1", + "@fortawesome/fontawesome-svg-core": "^6.5.2", + "@fortawesome/free-regular-svg-icons": "^6.5.2", + "@fortawesome/free-solid-svg-icons": "^6.5.2", "@ngneat/until-destroy": "^10.0.0", - "@sentry/browser": "^7.102.0", + "@sentry/browser": "^7.108.0", "angulartics2": "^12.2.1", "assert": "^2.1.0", "crypto-es": "^2.1.0", @@ -50,7 +50,7 @@ "hammerjs": "^2.0.8", "json-query": "^2.2.2", "keycloak-angular": "^15.1.0", - "keycloak-js": "^24.0.1", + "keycloak-js": "^24.0.2", "leaflet": "^1.9.4", "lodash-es": "^4.17.21", "md5": "^2.3.0", diff --git a/src/app/child-dev-project/attendance/add-day-attendance/roll-call-setup/roll-call-setup.component.ts b/src/app/child-dev-project/attendance/add-day-attendance/roll-call-setup/roll-call-setup.component.ts index 587bb96f18..91715bb309 100644 --- a/src/app/child-dev-project/attendance/add-day-attendance/roll-call-setup/roll-call-setup.component.ts +++ b/src/app/child-dev-project/attendance/add-day-attendance/roll-call-setup/roll-call-setup.component.ts @@ -9,7 +9,6 @@ import { AttendanceService } from "../../attendance.service"; import { Note } from "../../../notes/model/note"; import { EntityMapperService } from "../../../../core/entity/entity-mapper/entity-mapper.service"; import { RecurringActivity } from "../../model/recurring-activity"; -import { NoteDetailsComponent } from "../../../notes/note-details/note-details.component"; import { FormDialogService } from "../../../../core/form-dialog/form-dialog.service"; import { AlertService } from "../../../../core/alerts/alert.service"; import { AlertDisplay } from "../../../../core/alerts/alert-display"; @@ -197,7 +196,7 @@ export class RollCallSetupComponent implements OnInit { } this.formDialog - .openFormPopup(newNote, [], NoteDetailsComponent) + .openView(newNote, "NoteDetails") .afterClosed() .subscribe((createdNote: Note) => { if (createdNote) { diff --git a/src/app/child-dev-project/attendance/add-day-attendance/roll-call/roll-call.component.ts b/src/app/child-dev-project/attendance/add-day-attendance/roll-call/roll-call.component.ts index 9d295c5eb2..aef7c9428e 100644 --- a/src/app/child-dev-project/attendance/add-day-attendance/roll-call/roll-call.component.ts +++ b/src/app/child-dev-project/attendance/add-day-attendance/roll-call/roll-call.component.ts @@ -18,7 +18,6 @@ import { EntityMapperService } from "../../../../core/entity/entity-mapper/entit import { Child } from "../../../children/model/child"; import { LoggingService } from "../../../../core/logging/logging.service"; import { sortByAttribute } from "../../../../utils/utils"; -import { NoteDetailsComponent } from "../../../notes/note-details/note-details.component"; import { FormDialogService } from "../../../../core/form-dialog/form-dialog.service"; import { NgClass, NgForOf, NgIf } from "@angular/common"; import { MatProgressBarModule } from "@angular/material/progress-bar"; @@ -263,7 +262,7 @@ export class RollCallComponent implements OnChanges { } showDetails() { - this.formDialog.openFormPopup(this.eventEntity, [], NoteDetailsComponent); + this.formDialog.openView(this.eventEntity, "NoteDetails"); } async includeInactive() { diff --git a/src/app/child-dev-project/attendance/attendance-calendar/attendance-calendar.component.ts b/src/app/child-dev-project/attendance/attendance-calendar/attendance-calendar.component.ts index d4082dd081..e1067e4a1b 100644 --- a/src/app/child-dev-project/attendance/attendance-calendar/attendance-calendar.component.ts +++ b/src/app/child-dev-project/attendance/attendance-calendar/attendance-calendar.component.ts @@ -16,7 +16,6 @@ import moment, { Moment } from "moment"; import { EventAttendance } from "../model/event-attendance"; import { EntityMapperService } from "../../../core/entity/entity-mapper/entity-mapper.service"; import { FormDialogService } from "../../../core/form-dialog/form-dialog.service"; -import { NoteDetailsComponent } from "../../notes/note-details/note-details.component"; import { AverageAttendanceStats, calculateAverageAttendance, @@ -217,6 +216,6 @@ export class AttendanceCalendarComponent implements OnChanges { } showEventDetails(selectedEvent: Note) { - this.formDialog.openFormPopup(selectedEvent, [], NoteDetailsComponent); + this.formDialog.openView(selectedEvent, "NoteDetails"); } } diff --git a/src/app/child-dev-project/attendance/attendance-details/attendance-details.component.ts b/src/app/child-dev-project/attendance/attendance-details/attendance-details.component.ts index ac5950ac1a..97845a1ff7 100644 --- a/src/app/child-dev-project/attendance/attendance-details/attendance-details.component.ts +++ b/src/app/child-dev-project/attendance/attendance-details/attendance-details.component.ts @@ -1,6 +1,5 @@ import { Component, Inject, Input } from "@angular/core"; import { ActivityAttendance } from "../model/activity-attendance"; -import { NoteDetailsComponent } from "../../notes/note-details/note-details.component"; import { Note } from "../../notes/model/note"; import { calculateAverageAttendance } from "../model/calculate-average-event-attendance"; import { FormFieldConfig } from "../../../core/common-components/entity-form/FormConfig"; @@ -68,6 +67,6 @@ export class AttendanceDetailsComponent { } showEventDetails(event: EventNote) { - this.formDialog.openFormPopup(event, [], NoteDetailsComponent); + this.formDialog.openView(event, "NoteDetails"); } } diff --git a/src/app/child-dev-project/children/children-list/children-list.component.spec.ts b/src/app/child-dev-project/children/children-list/children-list.component.spec.ts index e080a43b13..ca6d940619 100644 --- a/src/app/child-dev-project/children/children-list/children-list.component.spec.ts +++ b/src/app/child-dev-project/children/children-list/children-list.component.spec.ts @@ -8,7 +8,6 @@ import { BooleanFilterConfig, EntityListConfig, } from "../../../core/entity-list/EntityListConfig"; -import { School } from "../../schools/model/school"; import { MockedTestingModule } from "../../../utils/mocked-testing.module"; import { DownloadService } from "../../../core/export/download-service/download.service"; @@ -101,9 +100,9 @@ describe("ChildrenListComponent", () => { const child1 = new Child("c1"); const child2 = new Child("c2"); mockChildrenService.getChildren.and.resolveTo([child1, child2]); - await component.ngOnInit(); + await component.ngOnChanges({}); expect(mockChildrenService.getChildren).toHaveBeenCalled(); - expect(component.childrenList).toEqual([child1, child2]); + expect(component.allEntities).toEqual([child1, child2]); }); }); diff --git a/src/app/child-dev-project/children/children-list/children-list.component.ts b/src/app/child-dev-project/children/children-list/children-list.component.ts index bd1c7ed4ff..e61c947d10 100644 --- a/src/app/child-dev-project/children/children-list/children-list.component.ts +++ b/src/app/child-dev-project/children/children-list/children-list.component.ts @@ -1,42 +1,107 @@ -import { Component, OnInit } from "@angular/core"; +import { Component } from "@angular/core"; import { Child } from "../model/child"; -import { ActivatedRoute } from "@angular/router"; +import { ActivatedRoute, Router, RouterLink } from "@angular/router"; import { ChildrenService } from "../children.service"; -import { EntityListConfig } from "../../../core/entity-list/EntityListConfig"; -import { DynamicComponentConfig } from "../../../core/config/dynamic-components/dynamic-component-config.interface"; import { EntityListComponent } from "../../../core/entity-list/entity-list/entity-list.component"; import { RouteTarget } from "../../../route-target"; +import { ScreenWidthObserver } from "../../../utils/media/screen-size-observer.service"; +import { EntityMapperService } from "../../../core/entity/entity-mapper/entity-mapper.service"; +import { EntityRegistry } from "../../../core/entity/database-entity.decorator"; +import { MatDialog } from "@angular/material/dialog"; +import { DuplicateRecordService } from "../../../core/entity-list/duplicate-records/duplicate-records.service"; +import { EntityActionsService } from "../../../core/entity/entity-actions/entity-actions.service"; +import { + AsyncPipe, + NgForOf, + NgIf, + NgStyle, + NgTemplateOutlet, +} from "@angular/common"; +import { MatButtonModule } from "@angular/material/button"; +import { Angulartics2OnModule } from "angulartics2"; +import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; +import { MatMenuModule } from "@angular/material/menu"; +import { MatTabsModule } from "@angular/material/tabs"; +import { MatFormFieldModule } from "@angular/material/form-field"; +import { MatInputModule } from "@angular/material/input"; +import { EntitiesTableComponent } from "../../../core/common-components/entities-table/entities-table.component"; +import { FormsModule } from "@angular/forms"; +import { FilterComponent } from "../../../core/filter/filter/filter.component"; +import { TabStateModule } from "../../../utils/tab-state/tab-state.module"; +import { ViewTitleComponent } from "../../../core/common-components/view-title/view-title.component"; +import { ExportDataDirective } from "../../../core/export/export-data-directive/export-data.directive"; +import { DisableEntityOperationDirective } from "../../../core/permissions/permission-directive/disable-entity-operation.directive"; +import { MatTooltipModule } from "@angular/material/tooltip"; +import { EntityCreateButtonComponent } from "../../../core/common-components/entity-create-button/entity-create-button.component"; +import { AbilityModule } from "@casl/angular"; +import { EntityActionsMenuComponent } from "../../../core/entity-details/entity-actions-menu/entity-actions-menu.component"; +import { ViewActionsComponent } from "../../../core/common-components/view-actions/view-actions.component"; @RouteTarget("ChildrenList") @Component({ selector: "app-children-list", - template: ` - - `, + templateUrl: + "../../../core/entity-list/entity-list/entity-list.component.html", + styleUrls: [ + "../../../core/entity-list/entity-list/entity-list.component.scss", + ], standalone: true, - imports: [EntityListComponent], + + imports: [ + NgIf, + NgStyle, + MatButtonModule, + Angulartics2OnModule, + FontAwesomeModule, + MatMenuModule, + NgTemplateOutlet, + MatTabsModule, + NgForOf, + MatFormFieldModule, + MatInputModule, + EntitiesTableComponent, + FormsModule, + FilterComponent, + TabStateModule, + ViewTitleComponent, + ExportDataDirective, + DisableEntityOperationDirective, + RouterLink, + MatTooltipModule, + EntityCreateButtonComponent, + AbilityModule, + AsyncPipe, + EntityActionsMenuComponent, + ViewActionsComponent, + ], }) -export class ChildrenListComponent implements OnInit { - childrenList: Child[]; - listConfig: EntityListConfig; - childConstructor = Child; +export class ChildrenListComponent extends EntityListComponent { + override entityConstructor = Child; constructor( + screenWidthObserver: ScreenWidthObserver, + router: Router, + activatedRoute: ActivatedRoute, + entityMapperService: EntityMapperService, + entities: EntityRegistry, + dialog: MatDialog, + duplicateRecord: DuplicateRecordService, + entityActionsService: EntityActionsService, private childrenService: ChildrenService, - private route: ActivatedRoute, - ) {} - - async ngOnInit() { - this.route.data.subscribe( - // TODO replace this use of route and rely on the RoutedViewComponent instead - // see that flattens the config option, assigning individual properties as inputs however, so we can't easily pass on - (data: DynamicComponentConfig) => - (this.listConfig = data.config), + ) { + super( + screenWidthObserver, + router, + activatedRoute, + entityMapperService, + entities, + dialog, + duplicateRecord, + entityActionsService, ); - this.childrenList = await this.childrenService.getChildren(); + } + + override async getEntities() { + return this.childrenService.getChildren(); } } diff --git a/src/app/child-dev-project/notes/dashboard-widgets/important-notes-dashboard/important-notes-dashboard.component.ts b/src/app/child-dev-project/notes/dashboard-widgets/important-notes-dashboard/important-notes-dashboard.component.ts index 0aaf1d6c1b..54ad4370ea 100644 --- a/src/app/child-dev-project/notes/dashboard-widgets/important-notes-dashboard/important-notes-dashboard.component.ts +++ b/src/app/child-dev-project/notes/dashboard-widgets/important-notes-dashboard/important-notes-dashboard.component.ts @@ -2,7 +2,6 @@ import { Component, Input } from "@angular/core"; import { Note } from "../../model/note"; import { DynamicComponent } from "../../../../core/config/dynamic-components/dynamic-component.decorator"; import { FormDialogService } from "../../../../core/form-dialog/form-dialog.service"; -import { NoteDetailsComponent } from "../../note-details/note-details.component"; import { DashboardListWidgetComponent } from "../../../../core/dashboard/dashboard-list-widget/dashboard-list-widget.component"; import { MatTableModule } from "@angular/material/table"; import { DatePipe, NgStyle } from "@angular/common"; @@ -37,6 +36,6 @@ export class ImportantNotesDashboardComponent extends DashboardWidget { } openNote(note: Note) { - this.formDialog.openFormPopup(note, [], NoteDetailsComponent); + this.formDialog.openView(note, "NoteDetails"); } } diff --git a/src/app/child-dev-project/notes/note-details/note-details.component.html b/src/app/child-dev-project/notes/note-details/note-details.component.html index ce73122f35..9256917b1a 100644 --- a/src/app/child-dev-project/notes/note-details/note-details.component.html +++ b/src/app/child-dev-project/notes/note-details/note-details.component.html @@ -1,24 +1,12 @@ - - -

{{ tmpEntity.date | date }}: {{ tmpEntity.subject }}

- +@if (isLoading || !tmpEntity) { +
+ +
+} @else { + + {{ tmpEntity.date | date }}: {{ tmpEntity.subject }} + -
@@ -57,31 +45,31 @@

{{ tmpEntity.date | date }}: {{ tmpEntity.subject }}

style="margin-top: 10px" >
-
- - - - - + + + + + +} diff --git a/src/app/child-dev-project/notes/note-details/note-details.component.ts b/src/app/child-dev-project/notes/note-details/note-details.component.ts index 6dfa813e7b..878cf0218e 100644 --- a/src/app/child-dev-project/notes/note-details/note-details.component.ts +++ b/src/app/child-dev-project/notes/note-details/note-details.component.ts @@ -1,9 +1,8 @@ import { Component, - Inject, Input, - OnInit, - Optional, + OnChanges, + SimpleChanges, ViewEncapsulation, } from "@angular/core"; import { Note } from "../model/note"; @@ -22,17 +21,29 @@ import { } from "../../../core/common-components/entity-form/entity-form.service"; import { EntityFormComponent } from "../../../core/common-components/entity-form/entity-form/entity-form.component"; import { DynamicComponentDirective } from "../../../core/config/dynamic-components/dynamic-component.directive"; -import { MAT_DIALOG_DATA, MatDialogModule } from "@angular/material/dialog"; +import { MatDialogModule } from "@angular/material/dialog"; import { DialogButtonsComponent } from "../../../core/form-dialog/dialog-buttons/dialog-buttons.component"; import { DialogCloseComponent } from "../../../core/common-components/dialog-close/dialog-close.component"; import { EntityArchivedInfoComponent } from "../../../core/entity-details/entity-archived-info/entity-archived-info.component"; import { EntityFieldEditComponent } from "../../../core/common-components/entity-field-edit/entity-field-edit.component"; import { FieldGroup } from "../../../core/entity-details/form/field-group"; +import { DynamicComponent } from "../../../core/config/dynamic-components/dynamic-component.decorator"; +import { ViewTitleComponent } from "../../../core/common-components/view-title/view-title.component"; +import { AbstractEntityDetailsComponent } from "../../../core/entity-details/abstract-entity-details/abstract-entity-details.component"; +import { EntityMapperService } from "../../../core/entity/entity-mapper/entity-mapper.service"; +import { EntityRegistry } from "../../../core/entity/database-entity.decorator"; +import { EntityAbility } from "../../../core/permissions/ability/entity-ability"; +import { Router } from "@angular/router"; +import { LoggingService } from "../../../core/logging/logging.service"; +import { UnsavedChangesService } from "../../../core/entity-details/form/unsaved-changes.service"; +import { MatProgressBar } from "@angular/material/progress-bar"; +import { ViewActionsComponent } from "../../../core/common-components/view-actions/view-actions.component"; /** * Component responsible for displaying the Note creation/view window */ @UntilDestroy() +@DynamicComponent("NoteDetails") @Component({ selector: "app-note-details", templateUrl: "./note-details.component.html", @@ -50,19 +61,33 @@ import { FieldGroup } from "../../../core/entity-details/form/field-group"; DialogCloseComponent, EntityArchivedInfoComponent, EntityFieldEditComponent, + ViewTitleComponent, + MatProgressBar, + ViewActionsComponent, ], standalone: true, encapsulation: ViewEncapsulation.None, }) -export class NoteDetailsComponent implements OnInit { +export class NoteDetailsComponent + extends AbstractEntityDetailsComponent + implements OnChanges +{ @Input() entity: Note; + entityConstructor = Note; /** export format for notes to be used for downloading the individual details */ exportConfig: ExportColumnConfig[]; - topForm = ["date", "warningLevel", "category", "authors", "attachment"]; - middleForm = ["subject", "text"]; - bottomForm = ["children", "schools"]; + @Input() topForm = [ + "date", + "warningLevel", + "category", + "authors", + "attachment", + ]; + @Input() middleForm = ["subject", "text"]; + @Input() bottomForm = ["children", "schools"]; + topFieldGroups: FieldGroup[]; bottomFieldGroups: FieldGroup[]; @@ -70,26 +95,32 @@ export class NoteDetailsComponent implements OnInit { tmpEntity: Note; constructor( + entityMapperService: EntityMapperService, + entities: EntityRegistry, + ability: EntityAbility, + router: Router, + logger: LoggingService, + unsavedChanges: UnsavedChangesService, private configService: ConfigService, private entityFormService: EntityFormService, - @Optional() @Inject(MAT_DIALOG_DATA) data: { entity: Note }, ) { - if (data) { - this.entity = data.entity; - } + super( + entityMapperService, + entities, + ability, + router, + logger, + unsavedChanges, + ); + this.exportConfig = this.configService.getConfig<{ config: EntityListConfig; }>("view:note")?.config.exportConfig; - - const formConfig = this.configService.getConfig( - "appConfig:note-details", - ); - this.topForm = formConfig?.topForm ?? this.topForm; - this.middleForm = formConfig?.middleForm ?? this.middleForm; - this.bottomForm = formConfig?.bottomForm ?? this.bottomForm; } - ngOnInit() { + async ngOnChanges(changes: SimpleChanges) { + await super.ngOnChanges(changes); + this.topFieldGroups = this.topForm.map((f) => ({ fields: [f] })); this.bottomFieldGroups = [{ fields: this.bottomForm }]; diff --git a/src/app/child-dev-project/notes/notes-components.ts b/src/app/child-dev-project/notes/notes-components.ts index 5afc78891d..7d4af46b86 100644 --- a/src/app/child-dev-project/notes/notes-components.ts +++ b/src/app/child-dev-project/notes/notes-components.ts @@ -51,4 +51,11 @@ export const notesComponents: ComponentTuple[] = [ "./dashboard-widgets/important-notes-dashboard/important-notes-dashboard.component" ).then((c) => c.ImportantNotesDashboardComponent), ], + [ + "NoteDetails", + () => + import("./note-details/note-details.component").then( + (c) => c.NoteDetailsComponent, + ), + ], ]; diff --git a/src/app/child-dev-project/notes/notes-manager/notes-manager.component.html b/src/app/child-dev-project/notes/notes-manager/notes-manager.component.html index cad8702d47..8ee604e28d 100644 --- a/src/app/child-dev-project/notes/notes-manager/notes-manager.component.html +++ b/src/app/child-dev-project/notes/notes-manager/notes-manager.component.html @@ -1,10 +1,16 @@ +} diff --git a/src/app/core/admin/setup-wizard/setup-wizard-button/setup-wizard-button.component.scss b/src/app/core/admin/setup-wizard/setup-wizard-button/setup-wizard-button.component.scss new file mode 100644 index 0000000000..89a33b0577 --- /dev/null +++ b/src/app/core/admin/setup-wizard/setup-wizard-button/setup-wizard-button.component.scss @@ -0,0 +1,7 @@ + +.button { + border-top: solid 1px rgba(0, 0, 0, 0.12); + border-radius: 0; + overflow: hidden; + width: 100%; +} diff --git a/src/app/core/admin/setup-wizard/setup-wizard-button/setup-wizard-button.component.spec.ts b/src/app/core/admin/setup-wizard/setup-wizard-button/setup-wizard-button.component.spec.ts new file mode 100644 index 0000000000..8f7a76ef83 --- /dev/null +++ b/src/app/core/admin/setup-wizard/setup-wizard-button/setup-wizard-button.component.spec.ts @@ -0,0 +1,50 @@ +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from "@angular/core/testing"; + +import { SetupWizardButtonComponent } from "./setup-wizard-button.component"; +import { EntityMapperService } from "../../../entity/entity-mapper/entity-mapper.service"; +import { mockEntityMapper } from "../../../entity/entity-mapper/mock-entity-mapper-service"; +import { SetupWizardConfig } from "../setup-wizard-config"; +import { Config } from "../../../config/config"; + +describe("SetupWizardButtonComponent", () => { + let component: SetupWizardButtonComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SetupWizardButtonComponent], + providers: [ + { provide: EntityMapperService, useValue: mockEntityMapper() }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SetupWizardButtonComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should hide if SetupWizardConfig completed", fakeAsync(() => { + const testWizardConfig: SetupWizardConfig = { + steps: [], + finished: true, + }; + spyOn(TestBed.inject(EntityMapperService), "load").and.resolveTo( + new Config("", testWizardConfig), + ); + + // @ts-ignore + component.init(); + tick(); + + expect(component.showSetupWizard).toBeFalse(); + })); +}); diff --git a/src/app/core/admin/setup-wizard/setup-wizard-button/setup-wizard-button.component.ts b/src/app/core/admin/setup-wizard/setup-wizard-button/setup-wizard-button.component.ts new file mode 100644 index 0000000000..c73af3e8ab --- /dev/null +++ b/src/app/core/admin/setup-wizard/setup-wizard-button/setup-wizard-button.component.ts @@ -0,0 +1,53 @@ +import { Component } from "@angular/core"; +import { FaIconComponent } from "@fortawesome/angular-fontawesome"; +import { MatButton } from "@angular/material/button"; +import { RouterLink } from "@angular/router"; +import { EntityMapperService } from "../../../entity/entity-mapper/entity-mapper.service"; +import { Config } from "../../../config/config"; +import { + CONFIG_SETUP_WIZARD_ID, + SetupWizardConfig, +} from "../setup-wizard-config"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; +import { filter } from "rxjs/operators"; +import { LoggingService } from "../../../logging/logging.service"; + +@UntilDestroy() +@Component({ + selector: "app-setup-wizard-button", + standalone: true, + imports: [FaIconComponent, MatButton, RouterLink], + templateUrl: "./setup-wizard-button.component.html", + styleUrls: ["./setup-wizard-button.component.scss"], +}) +export class SetupWizardButtonComponent { + showSetupWizard: boolean; + + constructor( + private entityMapper: EntityMapperService, + private logger: LoggingService, + ) { + this.init(); + } + + private init() { + this.entityMapper + .load(Config, CONFIG_SETUP_WIZARD_ID) + .then((r: Config) => { + this.updateStatus(r.data); + }) + .catch((e) => this.logger.debug("No Setup Wizard Config found")); + + this.entityMapper + .receiveUpdates>(Config) + .pipe( + untilDestroyed(this), + filter(({ entity }) => entity.getId() === CONFIG_SETUP_WIZARD_ID), + ) + .subscribe((update) => this.updateStatus(update.entity.data)); + } + + private updateStatus(config: SetupWizardConfig) { + this.showSetupWizard = !config.finished; + } +} diff --git a/src/app/core/admin/setup-wizard/setup-wizard-config.ts b/src/app/core/admin/setup-wizard/setup-wizard-config.ts new file mode 100644 index 0000000000..cb66af5971 --- /dev/null +++ b/src/app/core/admin/setup-wizard/setup-wizard-config.ts @@ -0,0 +1,106 @@ +import { MenuItem } from "../../ui/navigation/menu-item"; + +export const CONFIG_SETUP_WIZARD_ID = "Config:SetupWizard"; + +export interface SetupWizardConfig { + /** index of the current (last visited) step, to be opened when user returns to the wizard **/ + currentStep?: number; + + /** whether the wizard has been completed overall and should be hidden */ + finished?: boolean; + + steps: SetupWizardStep[]; +} + +export interface SetupWizardStep { + title: string; + text: string; + actions?: MenuItem[]; + + /** whether the user(s) have completed this step yet */ + completed?: boolean; +} + +export const defaultSetupWizardConfig: SetupWizardConfig = { + steps: [ + { + title: $localize`:Setup Wizard Step Title:Welcome`, + text: $localize`:Setup Wizard Step Text: +# Welcome to Aam Digital! +We are here to help you manage your participants' or beneficiaries' details +and your team's interactions with them. + +The Aam Digital platform is very flexible and you can customize the structures and views +to exactly fit your project needs. +The following steps guide you through the most important configuration options for this. +And you can start working with your data within a few minutes already. + +We also have some short video guides for you: [Aam Digital Video Guides (YouTube)](https://www.youtube.com/channel/UCZSFOX_MBa8zz5Mtfv_qlnA/videos) + +Feel free to leave this setup wizard in between to explore the existing system first. +You can always come back to this view through the "Setup Wizard" button at the bottom of the main menu on the left. +To dismiss and hide this wizard, go to the last step of the wizard and "finish" the setup process.`, + }, + { + title: $localize`:Setup Wizard Step Title:Profiles & Fields`, + text: $localize`:Setup Wizard Step Text: +The system already holds some basic structures for your case management. +You can adapt the fields and how the details are displayed. + +If you have further requirements, don't hesitate to reach out to us at [support@aam-digital.com](mailto:support@aam-digital.com). + +_Please note that the setup wizard and form builder is still under active development ("beta" version). +Some advanced configuration options are not available here yet for you to configure yourself and may need assistance from the tech support team. +We are currently extending and optimizing the user interfaces for these steps._ +`, + actions: [ + { + label: $localize`:Setup Wizard Step Action:Customize Child profile`, + link: "/admin/entity/Child", + }, + { + label: $localize`:Setup Wizard Step Action:Customize School profile`, + link: "/admin/entity/School", + }, + ], + }, + { + title: $localize`:Setup Wizard Step Title:User Accounts`, + text: $localize`:Setup Wizard Step Text: +You can collaborate on Aam Digital as a team. +Data is synced and all users have access to the latest information.`, + actions: [ + { + label: $localize`:Setup Wizard Step Action:Manage User Accounts`, + link: "/user", + }, + ], + }, + { + title: $localize`:Setup Wizard Step Title:Import Data`, + text: $localize`:Setup Wizard Step Text: +If you have exising data from a previous system, you can easily import it. +Save the data in ".csv" format (e.g. from MS Excel). +You do not need any specific column names in your file to be imported. +The Import Module helps your map your spreadsheet data to the relevant fields in your Aam Digital profiles.`, + actions: [ + { + label: $localize`:Setup Wizard Step Action:Import Data`, + link: "/import", + }, + ], + }, + { + title: $localize`:Setup Wizard Step Title:Done!`, + text: $localize`:Setup Wizard Step Text: +That's it. You are ready to explore your system and start work! + +You can always adapt your setup further, after you started using it. +We recommend to keep things simple in the beginning, +start using it for some of your tasks +and then add further fields and adjust your setup. + +Feel free to reach out to us with your questions or feedback: [support@aam-digital.com](mailto:support@aam-digital.com)`, + }, + ], +}; diff --git a/src/app/core/admin/setup-wizard/setup-wizard.component.html b/src/app/core/admin/setup-wizard/setup-wizard.component.html new file mode 100644 index 0000000000..66caffde84 --- /dev/null +++ b/src/app/core/admin/setup-wizard/setup-wizard.component.html @@ -0,0 +1,45 @@ + + + + {{ index + 1 }} + + + {{ index + 1 }} + + + @for (step of config?.steps; track step; let last = $last) { + + {{ step.text }} + + + @for (action of step.actions; track action.link) { + + } + + + @if (!last) { + + } @else { + + } + + } + diff --git a/src/app/core/admin/setup-wizard/setup-wizard.component.scss b/src/app/core/admin/setup-wizard/setup-wizard.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/core/admin/setup-wizard/setup-wizard.component.spec.ts b/src/app/core/admin/setup-wizard/setup-wizard.component.spec.ts new file mode 100644 index 0000000000..7584c29698 --- /dev/null +++ b/src/app/core/admin/setup-wizard/setup-wizard.component.spec.ts @@ -0,0 +1,79 @@ +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, +} from "@angular/core/testing"; + +import { SetupWizardComponent } from "./setup-wizard.component"; +import { EntityMapperService } from "../../entity/entity-mapper/entity-mapper.service"; +import { + mockEntityMapper, + MockEntityMapperService, +} from "../../entity/entity-mapper/mock-entity-mapper-service"; +import { + CONFIG_SETUP_WIZARD_ID, + defaultSetupWizardConfig, + SetupWizardConfig, +} from "./setup-wizard-config"; +import { Config } from "../../config/config"; + +describe("SetupWizardComponent", () => { + let component: SetupWizardComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SetupWizardComponent], + providers: [ + { provide: EntityMapperService, useValue: mockEntityMapper() }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(SetupWizardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should not save progress if no config was originally loaded", fakeAsync(() => { + const entityMapperSpy = spyOn(TestBed.inject(EntityMapperService), "save"); + + component.ngOnDestroy(); + tick(); + + expect(entityMapperSpy).not.toHaveBeenCalled(); + })); + + it("should load config on init and save progress upon leaving component", fakeAsync(() => { + const testConfig: SetupWizardConfig = JSON.parse( + JSON.stringify(defaultSetupWizardConfig), + ); + testConfig.currentStep = 2; + + const entityMapper: MockEntityMapperService = TestBed.inject( + EntityMapperService, + ) as MockEntityMapperService; + entityMapper.add(new Config(CONFIG_SETUP_WIZARD_ID, testConfig)); + const entityMapperSaveSpy = spyOn(entityMapper, "save"); + + component.ngOnInit(); + tick(); + expect(component.config).toEqual(testConfig); + expect(component.currentStep).toBe(2); + + component.currentStep = 3; + component.config.finished = true; + component.ngOnDestroy(); + tick(); + + expect(entityMapperSaveSpy).toHaveBeenCalled(); + const actualSavedConfig = entityMapperSaveSpy.calls.mostRecent() + .args[0] as Config; + expect(actualSavedConfig.data.finished).toBe(true); + expect(actualSavedConfig.data.currentStep).toBe(3); + })); +}); diff --git a/src/app/core/admin/setup-wizard/setup-wizard.component.ts b/src/app/core/admin/setup-wizard/setup-wizard.component.ts new file mode 100644 index 0000000000..14ebb659e1 --- /dev/null +++ b/src/app/core/admin/setup-wizard/setup-wizard.component.ts @@ -0,0 +1,72 @@ +import { Component, OnDestroy, OnInit } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { + MatStep, + MatStepper, + MatStepperIcon, + MatStepperNext, +} from "@angular/material/stepper"; +import { MatActionList, MatListItem } from "@angular/material/list"; +import { RouterLink } from "@angular/router"; +import { MatButton } from "@angular/material/button"; +import { EntityMapperService } from "../../entity/entity-mapper/entity-mapper.service"; +import { Config } from "../../config/config"; +import { + CONFIG_SETUP_WIZARD_ID, + SetupWizardConfig, +} from "./setup-wizard-config"; +import { MarkdownComponent } from "ngx-markdown"; +import { MatTooltip } from "@angular/material/tooltip"; +import { LoggingService } from "../../logging/logging.service"; + +@Component({ + selector: "app-setup-wizard", + standalone: true, + imports: [ + CommonModule, + MatStepper, + MatStep, + MatActionList, + MatListItem, + RouterLink, + MatButton, + MatStepperNext, + MatStepperIcon, + MarkdownComponent, + MatTooltip, + ], + templateUrl: "./setup-wizard.component.html", + styleUrl: "./setup-wizard.component.scss", +}) +export class SetupWizardComponent implements OnInit, OnDestroy { + config: SetupWizardConfig; + currentStep: number; + + private configEntity: Config; + + constructor( + private entityMapper: EntityMapperService, + private logger: LoggingService, + ) {} + + ngOnInit() { + this.entityMapper + .load(Config, CONFIG_SETUP_WIZARD_ID) + .then((r: Config) => { + this.configEntity = r; + this.config = r.data; + this.currentStep = this.config.currentStep; + }) + .catch((e) => this.logger.debug("no setup wizard config loaded", e)); + } + + ngOnDestroy(): void { + if (!this.configEntity) { + return; + } + + this.config.currentStep = this.currentStep; + this.configEntity.data = this.config; + this.entityMapper.save(this.configEntity); + } +} diff --git a/src/app/core/admin/setup-wizard/setup-wizard.stories.ts b/src/app/core/admin/setup-wizard/setup-wizard.stories.ts new file mode 100644 index 0000000000..7cf01b361a --- /dev/null +++ b/src/app/core/admin/setup-wizard/setup-wizard.stories.ts @@ -0,0 +1,30 @@ +import { + applicationConfig, + Meta, + moduleMetadata, + StoryFn, +} from "@storybook/angular"; +import { importProvidersFrom } from "@angular/core"; +import { StorybookBaseModule } from "../../../utils/storybook-base.module"; +import { SetupWizardComponent } from "./setup-wizard.component"; + +export default { + title: "Core/Admin/Setup Wizard", + component: SetupWizardComponent, + decorators: [ + applicationConfig({ + providers: [importProvidersFrom(StorybookBaseModule)], + }), + moduleMetadata({ + imports: [SetupWizardComponent], + }), + ], +} as Meta; + +const Template: StoryFn = (args) => ({ + component: SetupWizardComponent, + props: args, +}); + +export const Primary = Template.bind({}); +Primary.args = {}; diff --git a/src/app/core/common-components/anonymize-options/anonymize-options.component.html b/src/app/core/common-components/anonymize-options/anonymize-options.component.html new file mode 100644 index 0000000000..564cf8baa5 --- /dev/null +++ b/src/app/core/common-components/anonymize-options/anonymize-options.component.html @@ -0,0 +1,35 @@ + + + + + + + Remove + Retain + Partially Anonymize + + diff --git a/src/app/core/common-components/anonymize-options/anonymize-options.component.scss b/src/app/core/common-components/anonymize-options/anonymize-options.component.scss new file mode 100644 index 0000000000..c7acb4bf6e --- /dev/null +++ b/src/app/core/common-components/anonymize-options/anonymize-options.component.scss @@ -0,0 +1,3 @@ +mat-form-field { + width: 100%; +} diff --git a/src/app/core/common-components/anonymize-options/anonymize-options.component.spec.ts b/src/app/core/common-components/anonymize-options/anonymize-options.component.spec.ts new file mode 100644 index 0000000000..711b8843e5 --- /dev/null +++ b/src/app/core/common-components/anonymize-options/anonymize-options.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { AnonymizeOptionsComponent } from "./anonymize-options.component"; +import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; + +describe("AnonymizeOptionsComponent", () => { + let component: AnonymizeOptionsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AnonymizeOptionsComponent, BrowserAnimationsModule], + }).compileComponents(); + + fixture = TestBed.createComponent(AnonymizeOptionsComponent); + component = fixture.componentInstance; + + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/core/common-components/anonymize-options/anonymize-options.component.ts b/src/app/core/common-components/anonymize-options/anonymize-options.component.ts new file mode 100644 index 0000000000..21527bc9d7 --- /dev/null +++ b/src/app/core/common-components/anonymize-options/anonymize-options.component.ts @@ -0,0 +1,27 @@ +import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core"; +import { CommonModule } from "@angular/common"; +import { MatOptionModule } from "@angular/material/core"; +import { MatSelectModule } from "@angular/material/select"; +import { MatTooltipModule } from "@angular/material/tooltip"; + +/** + * Simple form field for admins to select the "anonymize" mode for an entity field. + * Displays tooltips as descriptions also. + */ +@Component({ + selector: "app-anonymize-options", + standalone: true, + imports: [CommonModule, MatOptionModule, MatSelectModule, MatTooltipModule], + templateUrl: "./anonymize-options.component.html", + styleUrl: "./anonymize-options.component.scss", +}) +export class AnonymizeOptionsComponent implements OnInit { + @Input() value: string; + @Output() valueChange = new EventEmitter(); + + ngOnInit(): void { + if (!this.value) { + this.value = ""; + } + } +} diff --git a/src/app/core/common-components/basic-autocomplete/basic-autocomplete.component.html b/src/app/core/common-components/basic-autocomplete/basic-autocomplete.component.html index b696caa698..5a79b602cd 100644 --- a/src/app/core/common-components/basic-autocomplete/basic-autocomplete.component.html +++ b/src/app/core/common-components/basic-autocomplete/basic-autocomplete.component.html @@ -33,25 +33,36 @@ autoActiveFirstOption [hideSingleSelectionIndicator]="multi" > - -
- - - - {{ item.asString }} - - - -
-
+ + +
+
+ +
+ + + {{ item.asString }} + + +
+
+
+ { initial: O; @@ -79,6 +84,7 @@ interface SelectableOption { MatTooltip, MatIcon, MatChipRemove, + DragDropModule, ], }) export class BasicAutocompleteComponent @@ -101,7 +107,13 @@ export class BasicAutocompleteComponent * Whether the user should be able to select multiple values. */ @Input() multi?: boolean; + @Input() reorder?: boolean; + /** + * Whether the user can manually drag & drop to reorder the selected items + */ + + autocompleteOptions: SelectableOption[] = []; autocompleteForm = new FormControl(""); autocompleteSuggestedOptions = this.autocompleteForm.valueChanges.pipe( filter((val) => typeof val === "string"), @@ -164,6 +176,12 @@ export class BasicAutocompleteComponent ); } + ngOnInit() { + this.autocompleteSuggestedOptions.subscribe((options) => { + this.autocompleteOptions = options; + }); + } + ngOnChanges(changes: { [key in keyof this]?: any }) { if (changes.valueMapper) { this._options.forEach( @@ -185,6 +203,25 @@ export class BasicAutocompleteComponent } } + drop(event: CdkDragDrop) { + if (event.previousContainer === event.container) { + moveItemInArray( + this.autocompleteOptions, + event.previousIndex, + event.currentIndex, + ); + } + this._selectedOptions = this.autocompleteOptions.filter((o) => o.selected); + if (this.multi) { + this.value = this._selectedOptions.map((o) => o.asValue); + } else { + this.value = undefined; + } + this.setInitialInputValue(); + this.onChange(this.value); + this.showAutocomplete(this.autocompleteForm.value); + } + showAutocomplete(valueToRevertTo?: string) { if (this.multi) { this.autocompleteForm.setValue(""); diff --git a/src/app/core/common-components/entity-form/entity-form.service.spec.ts b/src/app/core/common-components/entity-form/entity-form.service.spec.ts index 2f6a0e3b0c..723e2509e5 100644 --- a/src/app/core/common-components/entity-form/entity-form.service.spec.ts +++ b/src/app/core/common-components/entity-form/entity-form.service.spec.ts @@ -29,6 +29,8 @@ import { User } from "../../user/user"; import { TEST_USER } from "../../user/demo-user-generator.service"; import { CurrentUserSubject } from "../../session/current-user-subject"; import moment from "moment"; +import { EntityMapperService } from "../../entity/entity-mapper/entity-mapper.service"; +import { MockEntityMapperService } from "../../entity/entity-mapper/mock-entity-mapper-service"; describe("EntityFormService", () => { let service: EntityFormService; @@ -306,6 +308,25 @@ describe("EntityFormService", () => { Entity.schema.delete("test"); }); + it("should not save 'null' as value from empty form fields", async () => { + Entity.schema.set("test", { dataType: "string" }); + + const entity = new Entity(); + const form = service.createFormGroup([{ id: "test" }], entity); + form.get("test").reset(); + expect(form.get("test").getRawValue()).toEqual(null); + + await service.saveChanges(form, entity); + + const entityMapper = TestBed.inject( + EntityMapperService, + ) as MockEntityMapperService; + const actualSaved = entityMapper.get(entity.getType(), entity.getId()); + expect(actualSaved).toEqual(entity); + // service should remove 'null' value, which are the default for empty form fields + expect(actualSaved["test"]).not.toEqual(null); + }); + it("should add column definitions from property schema", () => { class Test extends Child { @DatabaseField({ diff --git a/src/app/core/common-components/entity-form/entity-form.service.ts b/src/app/core/common-components/entity-form/entity-form.service.ts index 9946d53330..779ccc4d29 100644 --- a/src/app/core/common-components/entity-form/entity-form.service.ts +++ b/src/app/core/common-components/entity-form/entity-form.service.ts @@ -237,10 +237,15 @@ export class EntityFormService { entity: T, ): Promise { this.checkFormValidity(form); + const updatedEntity = entity.copy() as T; - Object.assign(updatedEntity, form.getRawValue()); - updatedEntity.assertValid(); + for (const [key, value] of Object.entries(form.getRawValue())) { + if (value !== null) { + updatedEntity[key] = value; + } + } + updatedEntity.assertValid(); this.assertPermissionsToSave(entity, updatedEntity); return this.entityMapper diff --git a/src/app/core/common-components/view-actions/view-actions.component.html b/src/app/core/common-components/view-actions/view-actions.component.html new file mode 100644 index 0000000000..8b53fad673 --- /dev/null +++ b/src/app/core/common-components/view-actions/view-actions.component.html @@ -0,0 +1,7 @@ + + + + +@if (!viewContext) { + +} diff --git a/src/app/core/common-components/view-actions/view-actions.component.spec.ts b/src/app/core/common-components/view-actions/view-actions.component.spec.ts new file mode 100644 index 0000000000..2eafb8225a --- /dev/null +++ b/src/app/core/common-components/view-actions/view-actions.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { ViewActionsComponent } from "./view-actions.component"; + +describe("ViewActionsComponent", () => { + let component: ViewActionsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ViewActionsComponent], + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ViewActionsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/core/common-components/view-actions/view-actions.component.ts b/src/app/core/common-components/view-actions/view-actions.component.ts new file mode 100644 index 0000000000..b8670a1eb8 --- /dev/null +++ b/src/app/core/common-components/view-actions/view-actions.component.ts @@ -0,0 +1,31 @@ +import { + AfterViewInit, + Component, + Optional, + TemplateRef, + ViewChild, +} from "@angular/core"; +import { NgTemplateOutlet } from "@angular/common"; +import { ViewComponentContext } from "../../ui/abstract-view/abstract-view.component"; + +/** + * Building block for views, providing a consistent layout to action buttons and menus + * for both dialog and routed views. + */ +@Component({ + selector: "app-view-actions", + templateUrl: "./view-actions.component.html", + imports: [NgTemplateOutlet], + standalone: true, +}) +export class ViewActionsComponent implements AfterViewInit { + @ViewChild("template") template: TemplateRef; + + constructor(@Optional() protected viewContext: ViewComponentContext) {} + + ngAfterViewInit(): void { + if (this.viewContext) { + setTimeout(() => (this.viewContext.actions = this)); + } + } +} diff --git a/src/app/core/common-components/view-title/view-title.component.html b/src/app/core/common-components/view-title/view-title.component.html index f44acdc123..221e52158f 100644 --- a/src/app/core/common-components/view-title/view-title.component.html +++ b/src/app/core/common-components/view-title/view-title.component.html @@ -1,13 +1,21 @@ - + +
+ -

- -

+

+ +

+
+
+ +@if (!viewContext || displayInPlace) { + +} diff --git a/src/app/core/common-components/view-title/view-title.component.scss b/src/app/core/common-components/view-title/view-title.component.scss index f03d963386..a0e96c01ad 100644 --- a/src/app/core/common-components/view-title/view-title.component.scss +++ b/src/app/core/common-components/view-title/view-title.component.scss @@ -1,8 +1,9 @@ -:host { - display: flex; - flex-direction: row; +.container { align-items: center; margin-bottom: 0 !important; +} - max-width: 100%; +.back-button { + position: relative; + left: -12px; } diff --git a/src/app/core/common-components/view-title/view-title.component.ts b/src/app/core/common-components/view-title/view-title.component.ts index 2e53ddd4bb..01a6ec0ec4 100644 --- a/src/app/core/common-components/view-title/view-title.component.ts +++ b/src/app/core/common-components/view-title/view-title.component.ts @@ -1,25 +1,40 @@ import { + AfterViewInit, Component, HostBinding, Input, - OnChanges, - SimpleChanges, + Optional, + TemplateRef, + ViewChild, } from "@angular/core"; import { getUrlWithoutParams } from "../../../utils/utils"; import { Router } from "@angular/router"; -import { Location, NgIf } from "@angular/common"; +import { Location, NgIf, NgTemplateOutlet } from "@angular/common"; import { MatButtonModule } from "@angular/material/button"; import { MatTooltipModule } from "@angular/material/tooltip"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; +import { ViewComponentContext } from "../../ui/abstract-view/abstract-view.component"; +/** + * Building block for views, providing a consistent layout to a title section + * for both dialog and routed views. + */ @Component({ selector: "app-view-title", templateUrl: "./view-title.component.html", styleUrls: ["./view-title.component.scss"], - imports: [NgIf, MatButtonModule, MatTooltipModule, FontAwesomeModule], + imports: [ + NgIf, + MatButtonModule, + MatTooltipModule, + FontAwesomeModule, + NgTemplateOutlet, + ], standalone: true, }) -export class ViewTitleComponent implements OnChanges { +export class ViewTitleComponent implements AfterViewInit { + @ViewChild("template") template: TemplateRef; + /** The page title to be displayed */ @Input() title: string; @@ -36,8 +51,19 @@ export class ViewTitleComponent implements OnChanges { constructor( private router: Router, private location: Location, + @Optional() protected viewContext: ViewComponentContext, ) { this.parentUrl = this.findParentUrl(); + + if (this.viewContext?.isDialog) { + this.disableBackButton = true; + } + } + + ngAfterViewInit(): void { + if (this.viewContext && !this.displayInPlace) { + setTimeout(() => (this.viewContext.title = this)); + } } private findParentUrl(): string { @@ -59,23 +85,6 @@ export class ViewTitleComponent implements OnChanges { } } - ngOnChanges(changes: SimpleChanges) { - if (changes.hasOwnProperty("disableBackButton")) { - this.extraStyles = this.buildExtraStyles(); - } - } - - private buildExtraStyles() { - /* Moves the whole title component 12 pixels to the left so that - * the "go back" button is aligned with the left border. This class - * is applied conditionally when the "back" button is shown - */ - return { - position: "relative", - left: this.disableBackButton ? "unset" : "-12px", - }; - } - @HostBinding("class") extraClasses = "mat-title"; - @HostBinding("style") extraStyles = this.buildExtraStyles(); + @Input() displayInPlace!: boolean; } diff --git a/src/app/core/config/config-fix.ts b/src/app/core/config/config-fix.ts index 84b2a62b3a..99f72c9c75 100644 --- a/src/app/core/config/config-fix.ts +++ b/src/app/core/config/config-fix.ts @@ -161,7 +161,6 @@ export const defaultJsonConfig = { "view:note": { "component": "NotesManager", "config": { - "entity": "Note", "title": $localize`:Title for notes overview:Notes & Reports`, "includeEventNotes": false, "showEventNotesToggle": true, @@ -237,13 +236,19 @@ export const defaultJsonConfig = { ] } }, + "view:note/:id": { + "component": "NoteDetails", + "config": { + "topForm": ["date", "warningLevel", "category", "authors", "attachment"] + } + }, "view:import": { "component": "Import", }, "view:user": { "component": "EntityList", "config": { - "entity": "User", + "entityType": "User", "columns": ["name", "phone"] }, "permittedUserRoles": ["admin_app"] @@ -251,7 +256,7 @@ export const defaultJsonConfig = { "view:user/:id": { "component": "EntityDetails", "config": { - "entity": "User", + "entityType": "User", "panels": [ { "title": $localize`:Panel title:User Information`, @@ -294,7 +299,7 @@ export const defaultJsonConfig = { "view:school": { "component": "EntityList", "config": { - "entity": "School", + "entityType": "School", "columns": [ "name", { id: "DisplayParticipantsCount", viewComponent: "DisplayParticipantsCount", label: $localize`Children` }, @@ -312,7 +317,7 @@ export const defaultJsonConfig = { "view:school/:id": { "component": "EntityDetails", "config": { - "entity": "School", + "entityType": "School", "panels": [ { "title": $localize`:Panel title:Basic Information`, @@ -355,7 +360,7 @@ export const defaultJsonConfig = { "view:child": { "component": "ChildrenList", "config": { - "entity": "Child", + "entityType": "Child", "columns": [ { "viewComponent": "ChildBlock", @@ -485,7 +490,7 @@ export const defaultJsonConfig = { "view:child/:id": { "component": "EntityDetails", "config": { - "entity": "Child", + "entityType": "Child", "panels": [ { "title": $localize`:Panel title:Basic Information`, @@ -709,7 +714,7 @@ export const defaultJsonConfig = { "view:attendance/recurring-activity": { "component": "EntityList", "config": { - "entity": "RecurringActivity", + "entityType": "RecurringActivity", "columns": [ "title", "type", @@ -725,7 +730,7 @@ export const defaultJsonConfig = { "view:attendance/recurring-activity/:id": { "component": "EntityDetails", "config": { - "entity": "RecurringActivity", + "entityType": "RecurringActivity", "panels": [ { "title": $localize`:Panel title:Basic Information`, @@ -908,7 +913,7 @@ export const defaultJsonConfig = { "view:todo": { "component": "TodoList", "config": { - "entity": "Todo", + "entityType": "Todo", "columns": ["deadline", "subject", "assignedTo", "startDate", "relatedEntities"], "filters": [ {"id": "assignedTo"}, diff --git a/src/app/core/config/config.service.ts b/src/app/core/config/config.service.ts index 7c6ce7558d..e8ebc42d5e 100644 --- a/src/app/core/config/config.service.ts +++ b/src/app/core/config/config.service.ts @@ -58,6 +58,7 @@ export class ConfigService extends LatestEntityLoader { migrateFormHeadersIntoFieldGroups, migrateFormFieldConfigView2ViewComponent, migrateMenuItemConfig, + migrateEntityDetailsInputEntityType, ]; const newConfig = JSON.parse(JSON.stringify(config), (_that, rawValue) => { @@ -188,3 +189,25 @@ const migrateMenuItemConfig: ConfigMigration = (key, configPart) => { return configPart; }; + +/** + * Config properties specifying an entityType should be name "entityType" rather than "entity" + * to avoid confusion with a specific instance of an entity being passed in components. + * @param key + * @param configPart + */ +const migrateEntityDetailsInputEntityType: ConfigMigration = ( + key, + configPart, +) => { + if (key !== "config") { + return configPart; + } + + if (configPart["entity"]) { + configPart["entityType"] = configPart["entity"]; + delete configPart["entity"]; + } + + return configPart; +}; diff --git a/src/app/core/config/demo-config-generator.service.ts b/src/app/core/config/demo-config-generator.service.ts index baea66e329..f1a3dcae23 100644 --- a/src/app/core/config/demo-config-generator.service.ts +++ b/src/app/core/config/demo-config-generator.service.ts @@ -2,6 +2,10 @@ import { Injectable } from "@angular/core"; import { DemoDataGenerator } from "../demo-data/demo-data-generator"; import { Config } from "./config"; import { defaultJsonConfig } from "./config-fix"; +import { + CONFIG_SETUP_WIZARD_ID, + defaultSetupWizardConfig, +} from "../admin/setup-wizard/setup-wizard-config"; @Injectable() export class DemoConfigGeneratorService extends DemoDataGenerator { @@ -16,6 +20,9 @@ export class DemoConfigGeneratorService extends DemoDataGenerator { protected generateEntities(): Config[] { const defaultConfig = JSON.parse(JSON.stringify(defaultJsonConfig)); - return [new Config(Config.CONFIG_KEY, defaultConfig)]; + return [ + new Config(Config.CONFIG_KEY, defaultConfig), + new Config(CONFIG_SETUP_WIZARD_ID, defaultSetupWizardConfig), + ]; } } diff --git a/src/app/core/config/dynamic-components/dynamic-component.pipe.ts b/src/app/core/config/dynamic-components/dynamic-component.pipe.ts new file mode 100644 index 0000000000..ab582e35e5 --- /dev/null +++ b/src/app/core/config/dynamic-components/dynamic-component.pipe.ts @@ -0,0 +1,27 @@ +import { Pipe, PipeTransform, Type } from "@angular/core"; +import { ComponentRegistry } from "../../../dynamic-components"; + +/** + * Transform a string "component name" and load the referenced component. + * + * This is async and needs an additional async pipe. Use with *ngComponentOutlet +``` + +``` + */ +@Pipe({ + name: "dynamicComponent", + standalone: true, +}) +export class DynamicComponentPipe implements PipeTransform { + constructor(private componentRegistry: ComponentRegistry) {} + + async transform(value: string): Promise> { + return await this.componentRegistry.get(value)(); + } +} diff --git a/src/app/core/entity-details/EntityDetailsConfig.ts b/src/app/core/entity-details/EntityDetailsConfig.ts index 16d9e5e6b3..b78e1f6c56 100644 --- a/src/app/core/entity-details/EntityDetailsConfig.ts +++ b/src/app/core/entity-details/EntityDetailsConfig.ts @@ -7,7 +7,7 @@ export interface EntityDetailsConfig { /** * The name of the entity (according to the ENTITY_TYPE). */ - entity: string; + entityType: string; /** * The configuration for the panels on this details page. diff --git a/src/app/core/entity-details/abstract-entity-details/abstract-entity-details.component.spec.ts b/src/app/core/entity-details/abstract-entity-details/abstract-entity-details.component.spec.ts new file mode 100644 index 0000000000..ed402ad710 --- /dev/null +++ b/src/app/core/entity-details/abstract-entity-details/abstract-entity-details.component.spec.ts @@ -0,0 +1,129 @@ +import { + ComponentFixture, + fakeAsync, + TestBed, + tick, + waitForAsync, +} from "@angular/core/testing"; +import { AbstractEntityDetailsComponent } from "./abstract-entity-details.component"; +import { Router } from "@angular/router"; +import { EntityDetailsConfig } from "../EntityDetailsConfig"; +import { Child } from "../../../child-dev-project/children/model/child"; +import { MockedTestingModule } from "../../../utils/mocked-testing.module"; +import { EntityActionsService } from "../../entity/entity-actions/entity-actions.service"; +import { EntityAbility } from "../../permissions/ability/entity-ability"; +import { EntityMapperService } from "../../entity/entity-mapper/entity-mapper.service"; +import { Component, SimpleChange } from "@angular/core"; +import { mockEntityMapper } from "../../entity/entity-mapper/mock-entity-mapper-service"; + +@Component({ + template: ``, + standalone: true, +}) +class TestEntityDetailsComponent extends AbstractEntityDetailsComponent {} + +describe("AbstractEntityDetailsComponent", () => { + let component: TestEntityDetailsComponent; + let fixture: ComponentFixture; + + const routeConfig: EntityDetailsConfig = { + entityType: "Child", + panels: [], + }; + + let mockEntityRemoveService: jasmine.SpyObj; + let mockAbility: jasmine.SpyObj; + + beforeEach(waitForAsync(() => { + mockEntityRemoveService = jasmine.createSpyObj(["remove"]); + mockAbility = jasmine.createSpyObj(["cannot", "update", "on"]); + mockAbility.cannot.and.returnValue(false); + mockAbility.on.and.returnValue(() => true); + + TestBed.configureTestingModule({ + imports: [TestEntityDetailsComponent, MockedTestingModule.withState()], + providers: [ + { provide: EntityMapperService, useValue: mockEntityMapper() }, + { provide: EntityActionsService, useValue: mockEntityRemoveService }, + { provide: EntityAbility, useValue: mockAbility }, + ], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(TestEntityDetailsComponent); + component = fixture.componentInstance; + + Object.assign(component, routeConfig); + component.ngOnChanges( + simpleChangesFor(component, ...Object.keys(routeConfig)), + ); + + fixture.detectChanges(); + }); + + it("should create", () => { + expect(component).toBeTruthy(); + }); + + it("should load the correct entity on init", fakeAsync(() => { + component.isLoading = true; + const testChild = new Child("Test-Child"); + const entityMapper = TestBed.inject(EntityMapperService); + entityMapper.save(testChild); + tick(); + spyOn(entityMapper, "load").and.callThrough(); + + component.id = testChild.getId(true); + component.ngOnChanges(simpleChangesFor(component, "id")); + expect(component.isLoading).toBeTrue(); + tick(); + + expect(entityMapper.load).toHaveBeenCalledWith( + Child, + testChild.getId(true), + ); + expect(component.entity).toBe(testChild); + expect(component.isLoading).toBeFalse(); + })); + + it("should also support the long ID format", fakeAsync(() => { + const child = new Child(); + const entityMapper = TestBed.inject(EntityMapperService); + entityMapper.save(child); + tick(); + spyOn(entityMapper, "load").and.callThrough(); + + component.id = child.getId(); + component.ngOnChanges(simpleChangesFor(component, "id")); + tick(); + + expect(entityMapper.load).toHaveBeenCalledWith(Child, child.getId()); + expect(component.entity).toEqual(child); + + // entity is updated + const childUpdate = child.copy(); + childUpdate.name = "update"; + entityMapper.save(childUpdate); + tick(); + + expect(component.entity).toEqual(childUpdate); + })); + + it("should call router when user is not permitted to create entities", () => { + mockAbility.cannot.and.returnValue(true); + const router = fixture.debugElement.injector.get(Router); + spyOn(router, "navigate"); + component.id = "new"; + component.ngOnChanges(simpleChangesFor(component, "id")); + expect(router.navigate).toHaveBeenCalled(); + }); +}); + +function simpleChangesFor(component, ...properties: string[]) { + const changes = {}; + for (const p of properties) { + changes[p] = new SimpleChange(null, component[p], true); + } + return changes; +} diff --git a/src/app/core/entity-details/abstract-entity-details/abstract-entity-details.component.ts b/src/app/core/entity-details/abstract-entity-details/abstract-entity-details.component.ts new file mode 100644 index 0000000000..97edf9f480 --- /dev/null +++ b/src/app/core/entity-details/abstract-entity-details/abstract-entity-details.component.ts @@ -0,0 +1,79 @@ +import { Directive, Input, OnChanges, SimpleChanges } from "@angular/core"; +import { Router } from "@angular/router"; +import { Entity, EntityConstructor } from "../../entity/model/entity"; +import { EntityMapperService } from "../../entity/entity-mapper/entity-mapper.service"; +import { EntityAbility } from "../../permissions/ability/entity-ability"; +import { EntityRegistry } from "../../entity/database-entity.decorator"; +import { filter } from "rxjs/operators"; +import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; +import { Subscription } from "rxjs"; +import { LoggingService } from "../../logging/logging.service"; +import { UnsavedChangesService } from "../form/unsaved-changes.service"; + +/** + * This component can be used to display an entity in more detail. + * As an abstract base component, this provides functionality to load an entity + * and leaves the UI and special functionality to components that extend this class, like EntityDetailsComponent. + */ +@UntilDestroy() +@Directive() +export abstract class AbstractEntityDetailsComponent implements OnChanges { + isLoading: boolean; + private changesSubscription: Subscription; + + @Input() entityType: string; + entityConstructor: EntityConstructor; + + @Input() id: string; + @Input() entity: Entity; + + constructor( + private entityMapperService: EntityMapperService, + private entities: EntityRegistry, + private ability: EntityAbility, + private router: Router, + protected logger: LoggingService, + protected unsavedChanges: UnsavedChangesService, + ) {} + + async ngOnChanges(changes: SimpleChanges) { + if (changes.entityType) { + this.entityConstructor = this.entities.get(this.entityType); + } + + if (changes.id) { + await this.loadEntity(); + this.subscribeToEntityChanges(); + } + } + + protected subscribeToEntityChanges() { + const fullId = Entity.createPrefixedId(this.entityType, this.id); + this.changesSubscription?.unsubscribe(); + this.changesSubscription = this.entityMapperService + .receiveUpdates(this.entityConstructor) + .pipe( + filter(({ entity }) => entity.getId() === fullId), + filter(({ type }) => type !== "remove"), + untilDestroyed(this), + ) + .subscribe(({ entity }) => (this.entity = entity)); + } + + protected async loadEntity() { + this.isLoading = true; + if (this.id === "new") { + if (this.ability.cannot("create", this.entityConstructor)) { + this.router.navigate([""]); + return; + } + this.entity = new this.entityConstructor(); + } else { + this.entity = await this.entityMapperService.load( + this.entityConstructor, + this.id, + ); + } + this.isLoading = false; + } +} diff --git a/src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.html b/src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.html index dc0ba188be..9526faa07b 100644 --- a/src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.html +++ b/src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.html @@ -1,39 +1,66 @@ - +@if (!entity?.isNew) { + + @for (a of actions; track a.action) { + @if (showExpanded && viewContext?.isDialog && a.primaryAction) { + + } + } - - - + + - - - + + + + @for (a of actions; track a.action) { + @if (!a.primaryAction || !showExpanded || !viewContext?.isDialog) { + + } + } - - - + + + +} diff --git a/src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.scss b/src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.scss index e69de29bb2..4b374974e7 100644 --- a/src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.scss +++ b/src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.scss @@ -0,0 +1,4 @@ +:host { + display: flex; + align-items: center; +} diff --git a/src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.ts b/src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.ts index 625821d4af..923fc6db27 100644 --- a/src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.ts +++ b/src/app/core/entity-details/entity-actions-menu/entity-actions-menu.component.ts @@ -3,6 +3,7 @@ import { EventEmitter, Input, OnChanges, + Optional, Output, SimpleChanges, } from "@angular/core"; @@ -17,6 +18,7 @@ import { DisableEntityOperationDirective } from "../../permissions/permission-di import { IconProp } from "@fortawesome/fontawesome-svg-core"; import { EntityAction } from "../../permissions/permission-types"; import { MatTooltipModule } from "@angular/material/tooltip"; +import { ViewComponentContext } from "../../ui/abstract-view/abstract-view.component"; export type EntityMenuAction = "archive" | "anonymize" | "delete"; type EntityMenuActionItem = { @@ -26,6 +28,9 @@ type EntityMenuActionItem = { icon: IconProp; label: string; tooltip?: string; + + /** important action to be displayed directly, outside context menu in some views */ + primaryAction?: boolean; }; @Component({ @@ -68,6 +73,7 @@ export class EntityActionsMenuComponent implements OnChanges { icon: "box-archive", label: $localize`:entity context menu:Archive`, tooltip: $localize`:entity context menu tooltip:Mark the record as inactive, hiding it from lists by default while keeping the data.`, + primaryAction: true, }, { action: "anonymize", @@ -87,7 +93,15 @@ export class EntityActionsMenuComponent implements OnChanges { }, ]; - constructor(private entityRemoveService: EntityActionsService) {} + /** + * Whether some buttons should be displayed directly, outside the three-dot menu in dialog views. + */ + @Input() showExpanded?: boolean; + + constructor( + private entityRemoveService: EntityActionsService, + @Optional() protected viewContext: ViewComponentContext, + ) {} ngOnChanges(changes: SimpleChanges): void { if (changes.entity) { @@ -111,9 +125,13 @@ export class EntityActionsMenuComponent implements OnChanges { } async executeAction(action: EntityMenuActionItem) { - const result = await action.execute(this.entity, this.navigateOnDelete); + const result = await action.execute( + this.entity, + this.navigateOnDelete && !this.viewContext?.isDialog, + ); if (result) { this.actionTriggered.emit(action.action); } + setTimeout(() => this.filterAvailableActions()); } } diff --git a/src/app/core/entity-details/entity-details/entity-details.component.html b/src/app/core/entity-details/entity-details/entity-details.component.html index bcbc5eded0..fb46b34003 100644 --- a/src/app/core/entity-details/entity-details/entity-details.component.html +++ b/src/app/core/entity-details/entity-details/entity-details.component.html @@ -1,37 +1,23 @@ - + + + @if (!entity?.isNew) { + {{ entity?.toString() }} + } @else { + + Adding new {{ this.entityConstructor?.label }} + + } + -
- - {{ record?.toString() }} - - + - Adding new {{ this.entityConstructor?.label }} - - - -
+
- + - + + { let fixture: ComponentFixture; const routeConfig: EntityDetailsConfig = { - entity: "Child", + entityType: "Child", panels: [ { title: "One Form", @@ -83,9 +82,9 @@ describe("EntityDetailsComponent", () => { it("sets the panels config with child and creating status", fakeAsync(() => { const testChild = new Child("Test-Child"); + testChild["_rev"] = "1"; // mark as "not new" TestBed.inject(EntityMapperService).save(testChild); tick(); - component.creatingNew = false; component.id = testChild.getId(true); component.ngOnChanges(simpleChangesFor(component, "id")); tick(); @@ -98,59 +97,6 @@ describe("EntityDetailsComponent", () => { }), ); })); - - it("should load the correct child on startup", fakeAsync(() => { - component.isLoading = true; - const testChild = new Child("Test-Child"); - const entityMapper = TestBed.inject(EntityMapperService); - entityMapper.save(testChild); - tick(); - spyOn(entityMapper, "load").and.callThrough(); - - component.id = testChild.getId(true); - component.ngOnChanges(simpleChangesFor(component, "id")); - expect(component.isLoading).toBeTrue(); - tick(); - - expect(entityMapper.load).toHaveBeenCalledWith( - Child, - testChild.getId(true), - ); - expect(component.record).toBe(testChild); - expect(component.isLoading).toBeFalse(); - })); - - it("should also support the long ID format", fakeAsync(() => { - const child = new Child(); - const entityMapper = TestBed.inject(EntityMapperService); - entityMapper.save(child); - tick(); - spyOn(entityMapper, "load").and.callThrough(); - - component.id = child.getId(); - component.ngOnChanges(simpleChangesFor(component, "id")); - tick(); - - expect(entityMapper.load).toHaveBeenCalledWith(Child, child.getId()); - expect(component.record).toEqual(child); - - // entity is updated - const childUpdate = child.copy(); - childUpdate.name = "update"; - entityMapper.save(childUpdate); - tick(); - - expect(component.record).toEqual(childUpdate); - })); - - it("should call router when user is not permitted to create entities", () => { - mockAbility.cannot.and.returnValue(true); - const router = fixture.debugElement.injector.get(Router); - spyOn(router, "navigate"); - component.id = "new"; - component.ngOnChanges(simpleChangesFor(component, "id")); - expect(router.navigate).toHaveBeenCalled(); - }); }); function simpleChangesFor(component, ...properties: string[]) { diff --git a/src/app/core/entity-details/entity-details/entity-details.component.ts b/src/app/core/entity-details/entity-details/entity-details.component.ts index 1dcfe540fb..00124a88ea 100644 --- a/src/app/core/entity-details/entity-details/entity-details.component.ts +++ b/src/app/core/entity-details/entity-details/entity-details.component.ts @@ -1,11 +1,6 @@ import { Component, Input, OnChanges, SimpleChanges } from "@angular/core"; -import { Router, RouterLink } from "@angular/router"; +import { RouterLink } from "@angular/router"; import { Panel, PanelComponent, PanelConfig } from "../EntityDetailsConfig"; -import { Entity, EntityConstructor } from "../../entity/model/entity"; -import { EntityMapperService } from "../../entity/entity-mapper/entity-mapper.service"; -import { AnalyticsService } from "../../analytics/analytics.service"; -import { EntityAbility } from "../../permissions/ability/entity-ability"; -import { EntityRegistry } from "../../entity/database-entity.decorator"; import { MatButtonModule } from "@angular/material/button"; import { MatMenuModule } from "@angular/material/menu"; import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; @@ -18,15 +13,13 @@ import { CommonModule, NgForOf, NgIf } from "@angular/common"; import { ViewTitleComponent } from "../../common-components/view-title/view-title.component"; import { DynamicComponentDirective } from "../../config/dynamic-components/dynamic-component.directive"; import { DisableEntityOperationDirective } from "../../permissions/permission-directive/disable-entity-operation.directive"; -import { LoggingService } from "../../logging/logging.service"; -import { UnsavedChangesService } from "../form/unsaved-changes.service"; import { EntityActionsMenuComponent } from "../entity-actions-menu/entity-actions-menu.component"; import { EntityArchivedInfoComponent } from "../entity-archived-info/entity-archived-info.component"; -import { filter } from "rxjs/operators"; -import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; -import { Subscription } from "rxjs"; +import { UntilDestroy } from "@ngneat/until-destroy"; import { AbilityModule } from "@casl/angular"; import { RouteTarget } from "../../../route-target"; +import { AbstractEntityDetailsComponent } from "../abstract-entity-details/abstract-entity-details.component"; +import { ViewActionsComponent } from "../../common-components/view-actions/view-actions.component"; /** * This component can be used to display an entity in more detail. @@ -60,83 +53,26 @@ import { RouteTarget } from "../../../route-target"; RouterLink, AbilityModule, CommonModule, + ViewActionsComponent, ], }) -export class EntityDetailsComponent implements OnChanges { - creatingNew = false; - isLoading = true; - private changesSubscription: Subscription; - - /** @deprecated use "entityType" instead, this remains for config backwards compatibility */ - @Input() set entity(v: string) { - this.entityType = v; - } - @Input() entityType: string; - entityConstructor: EntityConstructor; - - @Input() id: string; - record: Entity; - +export class EntityDetailsComponent + extends AbstractEntityDetailsComponent + implements OnChanges +{ /** * The configuration for the panels on this details page. */ @Input() panels: Panel[] = []; - constructor( - private entityMapperService: EntityMapperService, - private router: Router, - private analyticsService: AnalyticsService, - private ability: EntityAbility, - private entities: EntityRegistry, - private logger: LoggingService, - public unsavedChanges: UnsavedChangesService, - ) {} + async ngOnChanges(changes: SimpleChanges) { + await super.ngOnChanges(changes); - ngOnChanges(changes: SimpleChanges): void { - if (changes.entity || changes.entityType) { - this.entityConstructor = this.entities.get(this.entityType); - } - if (changes.id) { - this.loadEntity(); - this.subscribeToEntityChanges(); - // `initPanels()` is already called inside `loadEntity()` - } else if (changes.panels) { + if (changes.id || changes.entity || changes.panels) { this.initPanels(); } } - private subscribeToEntityChanges() { - const fullId = Entity.createPrefixedId(this.entityType, this.id); - this.changesSubscription?.unsubscribe(); - this.changesSubscription = this.entityMapperService - .receiveUpdates(this.entityConstructor) - .pipe( - filter(({ entity }) => entity.getId() === fullId), - filter(({ type }) => type !== "remove"), - untilDestroyed(this), - ) - .subscribe(({ entity }) => (this.record = entity)); - } - - private async loadEntity() { - if (this.id === "new") { - if (this.ability.cannot("create", this.entityConstructor)) { - this.router.navigate([""]); - return; - } - this.record = new this.entityConstructor(); - this.creatingNew = true; - } else { - this.creatingNew = false; - this.record = await this.entityMapperService.load( - this.entityConstructor, - this.id, - ); - } - this.initPanels(); - this.isLoading = false; - } - private initPanels() { this.panels = this.panels.map((p) => ({ title: p.title, @@ -150,30 +86,14 @@ export class EntityDetailsComponent implements OnChanges { private getPanelConfig(c: PanelComponent): PanelConfig { let panelConfig: PanelConfig = { - entity: this.record, - creatingNew: this.creatingNew, + entity: this.entity, + creatingNew: this.entity.isNew, }; if (typeof c.config === "object" && !Array.isArray(c.config)) { - if (c.config?.entity) { - this.logger.warn( - `DEPRECATION panel config uses 'entity' keyword: ${JSON.stringify( - c, - )}`, - ); - c.config["entityType"] = c.config.entity; - delete c.config.entity; - } panelConfig = { ...c.config, ...panelConfig }; } else { panelConfig.config = c.config; } return panelConfig; } - - trackTabChanged(index: number) { - this.analyticsService.eventTrack("details_tab_changed", { - category: this.entityType, - label: this.panels[index].title, - }); - } } diff --git a/src/app/core/entity-details/entity-details/entity-details.stories.ts b/src/app/core/entity-details/entity-details/entity-details.stories.ts index f22b5d6add..7647e36dfd 100644 --- a/src/app/core/entity-details/entity-details/entity-details.stories.ts +++ b/src/app/core/entity-details/entity-details/entity-details.stories.ts @@ -9,7 +9,7 @@ const demoEntity = Child.create("John Doe"); demoEntity._rev = "1"; // make not "isNew" const config: EntityDetailsConfig = { - entity: "Child", + entityType: "Child", panels: [ { title: $localize`:Panel title:Basic Information`, diff --git a/src/app/core/entity-details/form/form.component.ts b/src/app/core/entity-details/form/form.component.ts index 76aaefcbb3..5aa979621a 100644 --- a/src/app/core/entity-details/form/form.component.ts +++ b/src/app/core/entity-details/form/form.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnInit } from "@angular/core"; +import { Component, Input, OnInit, Optional } from "@angular/core"; import { Entity } from "../../entity/model/entity"; import { getParentUrl } from "../../../utils/utils"; import { Router } from "@angular/router"; @@ -14,6 +14,7 @@ import { MatButtonModule } from "@angular/material/button"; import { EntityFormComponent } from "../../common-components/entity-form/entity-form/entity-form.component"; import { DisableEntityOperationDirective } from "../../permissions/permission-directive/disable-entity-operation.directive"; import { FieldGroup } from "./field-group"; +import { ViewComponentContext } from "../../ui/abstract-view/abstract-view.component"; /** * A simple wrapper function of the EntityFormComponent which can be used as a dynamic component @@ -45,6 +46,7 @@ export class FormComponent implements FormConfig, OnInit { private location: Location, private entityFormService: EntityFormService, private alertService: AlertService, + @Optional() private viewContext: ViewComponentContext, ) {} ngOnInit() { @@ -63,7 +65,7 @@ export class FormComponent implements FormConfig, OnInit { await this.entityFormService.saveChanges(this.form, this.entity); this.form.markAsPristine(); this.form.disable(); - if (this.creatingNew) { + if (this.creatingNew && !this.viewContext?.isDialog) { await this.router.navigate([ getParentUrl(this.router), this.entity.getId(true), diff --git a/src/app/core/entity-list/EntityListConfig.ts b/src/app/core/entity-list/EntityListConfig.ts index f2bfc9ac44..b2b3424e4a 100644 --- a/src/app/core/entity-list/EntityListConfig.ts +++ b/src/app/core/entity-list/EntityListConfig.ts @@ -14,7 +14,7 @@ export interface EntityListConfig { * Select which entities should be displayed in the table * (optional) This is only used and necessary if EntityListComponent is used directly in config */ - entity?: string; + entityType?: string; /** * Custom overwrites or additional columns to be displayed in the table. diff --git a/src/app/core/entity-list/entity-list/entity-list.component.html b/src/app/core/entity-list/entity-list/entity-list.component.html index b5895146ea..a618c191a4 100644 --- a/src/app/core/entity-list/entity-list/entity-list.component.html +++ b/src/app/core/entity-list/entity-list/entity-list.component.html @@ -1,12 +1,11 @@
+ + {{ title }} + -
- - {{ title }} - - +
@@ -19,7 +18,7 @@
-
+ @@ -211,6 +210,7 @@

{{ title }}

(click)="selectedRows = []" matTooltip="Select multiple records for bulk actions like duplicating or deleting" i18n-matTooltip + matTooltipPosition="before" > { } component.entityConstructor = Test; - component.listConfig = { - title: "", - columns: [ - { - id: "anotherColumn", - label: "Predefined Title", - viewComponent: "DisplayDate", - }, - ], - columnGroups: { - groups: [{ name: "Both", columns: ["testProperty", "anotherColumn"] }], + component.columns = [ + { + id: "anotherColumn", + label: "Predefined Title", + viewComponent: "DisplayDate", }, + ]; + component.columnGroups = { + groups: [{ name: "Both", columns: ["testProperty", "anotherColumn"] }], }; component.ngOnChanges({ listConfig: null }); @@ -177,30 +174,6 @@ describe("EntityListComponent", () => { ]); })); - it("should automatically initialize values if directly referenced from config", fakeAsync(() => { - mockActivatedRoute.component = EntityListComponent; - const entityMapper = TestBed.inject(EntityMapperService); - const children = [new Child(), new Child()]; - spyOn(entityMapper, "loadType").and.resolveTo(children); - - createComponent(); - component.listConfig = { - entity: "Child", - title: "Some title", - columns: ["name", "gender"], - }; - component.ngOnChanges({ listConfig: undefined }); - tick(); - - expect(component.entityConstructor).toBe(Child); - expect(component.allEntities).toEqual(children); - expect(component.title).toBe("Some title"); - - const navigateSpy = spyOn(TestBed.inject(Router), "navigate"); - component.addNew(); - expect(navigateSpy.calls.mostRecent().args[0]).toEqual(["new"]); - })); - it("should not navigate on addNew if clickMode is not 'navigate'", () => { createComponent(); const navigateSpy = spyOn(TestBed.inject(Router), "navigate"); @@ -257,10 +230,9 @@ describe("EntityListComponent", () => { } async function initComponentInputs() { - component.listConfig = testConfig; + Object.assign(component, testConfig); await component.ngOnChanges({ allEntities: undefined, - listConfig: undefined, }); fixture.detectChanges(); } diff --git a/src/app/core/entity-list/entity-list/entity-list.component.ts b/src/app/core/entity-list/entity-list/entity-list.component.ts index 2d32087907..8d9231f7f7 100644 --- a/src/app/core/entity-list/entity-list/entity-list.component.ts +++ b/src/app/core/entity-list/entity-list/entity-list.component.ts @@ -15,7 +15,6 @@ import { } from "../EntityListConfig"; import { Entity, EntityConstructor } from "../../entity/model/entity"; import { FormFieldConfig } from "../../common-components/entity-form/FormConfig"; -import { AnalyticsService } from "../../analytics/analytics.service"; import { EntityMapperService } from "../../entity/entity-mapper/entity-mapper.service"; import { EntityRegistry } from "../../entity/database-entity.decorator"; import { ScreenWidthObserver } from "../../../utils/media/screen-size-observer.service"; @@ -55,6 +54,7 @@ import { DataFilter } from "../../filter/filters/filters"; import { EntityCreateButtonComponent } from "../../common-components/entity-create-button/entity-create-button.component"; import { AbilityModule } from "@casl/angular"; import { EntityActionsMenuComponent } from "../../entity-details/entity-actions-menu/entity-actions-menu.component"; +import { ViewActionsComponent } from "../../common-components/view-actions/view-actions.component"; /** * This component allows to create a full-blown table with pagination, filtering, searching and grouping. @@ -96,6 +96,8 @@ import { EntityActionsMenuComponent } from "../../entity-details/entity-actions- AbilityModule, AsyncPipe, EntityActionsMenuComponent, + ViewActionsComponent, + // WARNING: all imports here also need to be set for components extending EntityList, like ChildrenListComponent ], standalone: true, }) @@ -105,10 +107,7 @@ export class EntityListComponent { @Input() allEntities: T[]; - /** @deprecated this is often used when this has a wrapper component (e.g. ChildrenList), preferably use individual @Input properties */ - @Input() listConfig: EntityListConfig; - - @Input() entity: string; + @Input() entityType: string; @Input() entityConstructor: EntityConstructor; @Input() defaultSort: Sort; @Input() exportConfig: ExportColumnConfig[]; @@ -167,8 +166,7 @@ export class EntityListComponent private screenWidthObserver: ScreenWidthObserver, private router: Router, private activatedRoute: ActivatedRoute, - private analyticsService: AnalyticsService, - private entityMapperService: EntityMapperService, + protected entityMapperService: EntityMapperService, private entities: EntityRegistry, private dialog: MatDialog, private duplicateRecord: DuplicateRecordService, @@ -192,16 +190,13 @@ export class EntityListComponent } ngOnChanges(changes: SimpleChanges) { - if (changes.hasOwnProperty("listConfig")) { - Object.assign(this, this.listConfig); - } return this.buildComponentFromConfig(); } private async buildComponentFromConfig() { - if (this.entity) { + if (this.entityType) { this.entityConstructor = this.entities.get( - this.entity, + this.entityType, ) as EntityConstructor; } @@ -221,13 +216,19 @@ export class EntityListComponent ); } - private async loadEntities() { - this.allEntities = await this.entityMapperService.loadType( - this.entityConstructor, - ); + protected async loadEntities() { + this.allEntities = await this.getEntities(); this.listenToEntityUpdates(); } + /** + * Template method that can be overwritten to change the loading logic. + * @protected + */ + protected async getEntities(): Promise { + return this.entityMapperService.loadType(this.entityConstructor); + } + private updateSubscription: Subscription; private listenToEntityUpdates() { @@ -262,10 +263,6 @@ export class EntityListComponent applyFilter(filterValue: string) { // TODO: turn this into one of our filter types, so that all filtering happens the same way (and we avoid accessing internal datasource of sub-component here) this.filterFreetext = filterValue.trim().toLowerCase(); - - this.analyticsService.eventTrack("list_filter_freetext", { - category: this.entityConstructor?.ENTITY_TYPE, - }); } private displayColumnGroupByName(columnGroupName: string) { diff --git a/src/app/core/entity/entity-actions/entity-anonymize.service.spec.ts b/src/app/core/entity/entity-actions/entity-anonymize.service.spec.ts index 1befc74733..ee109b7203 100644 --- a/src/app/core/entity/entity-actions/entity-anonymize.service.spec.ts +++ b/src/app/core/entity/entity-actions/entity-anonymize.service.spec.ts @@ -98,7 +98,7 @@ describe("EntityAnonymizeService", () => { } } - it("should anonymize and only keep properties marked to be retained", async () => { + it("should anonymize and only keep properties marked to be retained, setting others to 'null'", async () => { const entity = new AnonymizableEntity(); entity.defaultField = "test"; entity.retainedField = "test"; @@ -107,13 +107,12 @@ describe("EntityAnonymizeService", () => { AnonymizableEntity.expectAnonymized( entity.getId(), - AnonymizableEntity.create({ retainedField: "test" }), + AnonymizableEntity.create({ retainedField: "test", defaultField: null }), ); }); it("should anonymize and keep empty record without any fields", async () => { const entity = new AnonymizableEntity(); - entity.defaultField = "test"; await service.anonymizeEntity(entity); @@ -140,6 +139,7 @@ describe("EntityAnonymizeService", () => { AnonymizableEntity.create({ inactive: true, anonymized: true, + defaultField: null, ...entityProperties, }), true, @@ -154,7 +154,11 @@ describe("EntityAnonymizeService", () => { AnonymizableEntity.expectAnonymized( entity.getId(), - AnonymizableEntity.create({ inactive: true, anonymized: true }), + AnonymizableEntity.create({ + inactive: true, + anonymized: true, + defaultField: null, + }), true, ); }); @@ -187,7 +191,7 @@ describe("EntityAnonymizeService", () => { AnonymizableEntity.expectAnonymized( entity.getId(), - AnonymizableEntity.create({}), + AnonymizableEntity.create({ file: null }), ); expect(mockFileService.removeFile).toHaveBeenCalled(); }); @@ -234,6 +238,12 @@ describe("EntityAnonymizeService", () => { expectedAnonymizedEntity.inactive = true; expectedAnonymizedEntity.anonymized = true; + for (const [k, v] of Object.entries(actualEntity)) { + if (v === null) { + expectedAnonymizedEntity[k] = null; + } + } + expect(comparableEntityData(actualEntity)).toEqual( comparableEntityData(expectedAnonymizedEntity), ); diff --git a/src/app/core/entity/entity-actions/entity-anonymize.service.ts b/src/app/core/entity/entity-actions/entity-anonymize.service.ts index 731e5757e6..252f20cf21 100644 --- a/src/app/core/entity/entity-actions/entity-anonymize.service.ts +++ b/src/app/core/entity/entity-actions/entity-anonymize.service.ts @@ -92,7 +92,9 @@ export class EntityAnonymizeService extends CascadingEntityAction { await firstValueFrom(this.fileService.removeFile(entity, key)); } - delete entity[key]; + if (entity[key] !== undefined) { + entity[key] = null; + } } private async keepEntityUnchanged(e: Entity): Promise { diff --git a/src/app/core/entity/entity-config.service.ts b/src/app/core/entity/entity-config.service.ts index a17e7cbc71..a158aaa348 100644 --- a/src/app/core/entity/entity-config.service.ts +++ b/src/app/core/entity/entity-config.service.ts @@ -5,9 +5,14 @@ import { EntityRegistry } from "./database-entity.decorator"; import { IconName } from "@fortawesome/fontawesome-svg-core"; import { EntityConfig } from "./entity-config"; import { addPropertySchema } from "./database-field.decorator"; -import { PREFIX_VIEW_CONFIG } from "../config/dynamic-routing/view-config.interface"; +import { + PREFIX_VIEW_CONFIG, + ViewConfig, +} from "../config/dynamic-routing/view-config.interface"; import { EntitySchemaField } from "./schema/entity-schema-field"; import { EntitySchema } from "./schema/entity-schema"; +import { EntityDetailsConfig } from "../entity-details/EntityDetailsConfig"; +import { EntityListConfig } from "../entity-list/EntityListConfig"; /** * A service that allows to work with configuration-objects @@ -149,4 +154,19 @@ export class EntityConfigService { EntityConfigService.PREFIX_ENTITY_CONFIG + entityType.ENTITY_TYPE; return this.configService.getConfig(configName); } + + getDetailsViewConfig( + entityType: EntityConstructor, + ): ViewConfig { + return this.configService.getConfig>( + EntityConfigService.getDetailsViewId(entityType), + ); + } + getListViewConfig( + entityType: EntityConstructor, + ): ViewConfig { + return this.configService.getConfig>( + EntityConfigService.getListViewId(entityType), + ); + } } diff --git a/src/app/core/entity/schema/entity-schema.service.spec.ts b/src/app/core/entity/schema/entity-schema.service.spec.ts index bd5f59455d..3868265e62 100644 --- a/src/app/core/entity/schema/entity-schema.service.spec.ts +++ b/src/app/core/entity/schema/entity-schema.service.spec.ts @@ -58,6 +58,25 @@ describe("EntitySchemaService", () => { expect(rawData.aString).toEqual("192"); }); + it("should keep 'null' as value if explicitly set", () => { + class TestEntity extends Entity { + @DatabaseField() aString: string; + } + + const entity = new TestEntity(); + + const data = { + _id: entity.getId(), + aString: null, + }; + service.loadDataIntoEntity(entity, data); + + expect(entity.aString).toEqual(null); + + const rawData = service.transformEntityToDatabaseFormat(entity); + expect(rawData.aString).toEqual(null); + }); + it("should return the directly defined component name for viewing and editing a property", () => { class Test extends Entity { @DatabaseField({ diff --git a/src/app/core/entity/schema/entity-schema.service.ts b/src/app/core/entity/schema/entity-schema.service.ts index 4fe5075420..4a5ad4a7e8 100644 --- a/src/app/core/entity/schema/entity-schema.service.ts +++ b/src/app/core/entity/schema/entity-schema.service.ts @@ -104,7 +104,7 @@ export class EntitySchemaService { for (const key of schema.keys()) { const schemaField: EntitySchemaField = schema.get(key); - if (data[key] === undefined || data[key] === null) { + if (data[key] === undefined) { continue; } @@ -153,7 +153,7 @@ export class EntitySchemaService { let value = entity[key]; const schemaField: EntitySchemaField = schema.get(key); - if (value === undefined || value === null) { + if (value === undefined) { // skip and keep undefined continue; } @@ -218,6 +218,11 @@ export class EntitySchemaService { schemaField: EntitySchemaField, entity?: Entity, ) { + if (value === null) { + // keep 'null' to be able to explicitly mark a value as being reset + return null; + } + return this.getDatatypeOrDefault( schemaField.dataType, ).transformToDatabaseFormat(value, schemaField, entity); @@ -234,6 +239,11 @@ export class EntitySchemaService { schemaField: EntitySchemaField, dataObject?: any, ) { + if (value === null) { + // keep 'null' to be able to explicitly mark a value as being reset + return null; + } + return this.getDatatypeOrDefault( schemaField.dataType, ).transformToObjectFormat(value, schemaField, dataObject); diff --git a/src/app/core/export/download-service/download.service.spec.ts b/src/app/core/export/download-service/download.service.spec.ts index 8c72ee576f..b2b34c4727 100644 --- a/src/app/core/export/download-service/download.service.spec.ts +++ b/src/app/core/export/download-service/download.service.spec.ts @@ -8,15 +8,23 @@ import { Entity } from "../../entity/model/entity"; import { ConfigurableEnumValue } from "../../basic-datatypes/configurable-enum/configurable-enum.interface"; import { DatabaseField } from "../../entity/database-field.decorator"; import moment from "moment"; +import { EntityMapperService } from "app/core/entity/entity-mapper/entity-mapper.service"; +import { School } from "app/child-dev-project/schools/model/school"; +import { Child } from "app/child-dev-project/children/model/child"; +import { mockEntityMapper } from "app/core/entity/entity-mapper/mock-entity-mapper-service"; describe("DownloadService", () => { let service: DownloadService; let mockDataTransformationService: jasmine.SpyObj; + let mockedEntityMapper; + const testSchool = School.create({ name: "Test School" }); + const testChild = Child.create("Test Child"); beforeEach(() => { mockDataTransformationService = jasmine.createSpyObj([ "queryAndTransformData", ]); + mockedEntityMapper = mockEntityMapper([testSchool, testChild]); TestBed.configureTestingModule({ providers: [ DownloadService, @@ -24,6 +32,10 @@ describe("DownloadService", () => { provide: DataTransformationService, useValue: mockDataTransformationService, }, + { + provide: EntityMapperService, + useValue: mockedEntityMapper, + }, LoggingService, ], }); @@ -78,7 +90,7 @@ describe("DownloadService", () => { '"_id","_rev","propOne","propTwo"' + DownloadService.SEPARATOR_ROW + '"TestForCsvEntity:1","2","first","second"'; - spyOn(service, "exportFile").and.returnValue(expected); + spyOn(service, "exportFile").and.resolveTo(expected); const result = await service.createCsv([test]); expect(result).toEqual(expected); }); @@ -114,6 +126,66 @@ describe("DownloadService", () => { expect(columnValues).toContain('"true"'); }); + it("should add columns with entity toString for referenced entities in export", async () => { + class EntityRefDownloadTestEntity extends Entity { + @DatabaseField({ dataType: "entity", label: "referenced entity" }) + relatedEntity: string; + @DatabaseField({ dataType: "entity", label: "referenced entity 2" }) + relatedEntity2: string; + } + const relatedEntity = testSchool; + const relatedEntity2 = testChild; + + const testEntity = new EntityRefDownloadTestEntity(); + testEntity.relatedEntity = relatedEntity.getId(); + testEntity.relatedEntity2 = relatedEntity2.getId(); + + const csvExport = await service.createCsv([testEntity]); + + const rows = csvExport.split(DownloadService.SEPARATOR_ROW); + expect(rows).toHaveSize(1 + 1); // includes 1 header line + const columnValues = rows[1].split(DownloadService.SEPARATOR_COL); + expect(columnValues).toHaveSize(4); + expect(columnValues).toContain('"' + relatedEntity.getId() + '"'); + expect(columnValues).toContain('"' + relatedEntity.toString() + '"'); + expect(columnValues).toContain('"' + relatedEntity2.getId() + '"'); + expect(columnValues).toContain('"' + relatedEntity2.toString() + '"'); + }); + + it("should add column with entity toString for referenced array of entities in export", async () => { + class EntityRefDownloadTestEntity extends Entity { + @DatabaseField({ dataType: "entity-array", label: "referenced entities" }) + relatedEntitiesArray: string[]; + } + const testEntity = new EntityRefDownloadTestEntity(); + testEntity.relatedEntitiesArray = [testSchool.getId(), testChild.getId()]; + + const csvExport = await service.createCsv([testEntity]); + + const rows = csvExport.split(DownloadService.SEPARATOR_ROW); + expect(rows).toHaveSize(1 + 1); // includes 1 header line + expect(rows[1]).toBe( + `"${testSchool.getId()},${testChild.getId()}","${testSchool.toString()},${testChild.toString()}"`, + ); + }); + + it("should handle undefined entity ids without errors", async () => { + class EntityRefDownloadTestEntity extends Entity { + @DatabaseField({ dataType: "entity-array", label: "referenced entities" }) + relatedEntitiesArray: string[]; + } + const testEntity = new EntityRefDownloadTestEntity(); + testEntity.relatedEntitiesArray = ["undefined-id", testChild.getId()]; + + const csvExport = await service.createCsv([testEntity]); + + const rows = csvExport.split(DownloadService.SEPARATOR_ROW); + expect(rows).toHaveSize(1 + 1); // includes 1 header line + expect(rows[1]).toBe( + `"undefined-id,${testChild.getId()}",",${testChild.toString()}"`, + ); + }); + it("should export all properties using object keys as headers, if no schema is available", async () => { const docs = [ { _id: "Test:1", name: "Child 1" }, diff --git a/src/app/core/export/download-service/download.service.ts b/src/app/core/export/download-service/download.service.ts index 85012822c6..f69d8db225 100644 --- a/src/app/core/export/download-service/download.service.ts +++ b/src/app/core/export/download-service/download.service.ts @@ -5,7 +5,10 @@ import { LoggingService } from "../../logging/logging.service"; import { DataTransformationService } from "../data-transformation-service/data-transformation.service"; import { transformToReadableFormat } from "../../common-components/entities-table/value-accessor/value-accessor"; import { Papa } from "ngx-papaparse"; -import { EntitySchemaField } from "app/core/entity/schema/entity-schema-field"; +import { Entity, EntityConstructor } from "app/core/entity/model/entity"; +import { EntityDatatype } from "app/core/basic-datatypes/entity/entity.datatype"; +import { EntityArrayDatatype } from "app/core/basic-datatypes/entity-array/entity-array.datatype"; +import { EntityMapperService } from "app/core/entity/entity-mapper/entity-mapper.service"; /** * This service allows to start a download process from the browser. @@ -22,6 +25,7 @@ export class DownloadService { private dataTransformationService: DataTransformationService, private papa: Papa, private loggingService: LoggingService, + private entityMapperService: EntityMapperService, ) {} /** @@ -110,27 +114,33 @@ export class DownloadService { }); } - const result = this.exportFile(data, entityConstructor); + const result = await this.exportFile(data, entityConstructor); return result; } - exportFile(data: any[], entityConstructor: { schema: any }) { + async exportFile(data: any[], entityConstructor: EntityConstructor) { const entitySchema = entityConstructor.schema; - const columnLabels = new Map(); - - entitySchema.forEach((value: { label: EntitySchemaField }, key: string) => { - if (value.label) columnLabels.set(key, value.label); - }); - - const exportEntities = data.map((item) => { - let newItem = {}; - for (const key in item) { - if (columnLabels.has(key)) { - newItem[key] = item[key]; - } + const columnLabels = new Map(); + + for (const [id, field] of entitySchema.entries()) { + if (!field.label) { + // skip "technical" fields without an explicit label + continue; } - return newItem; - }); + + columnLabels.set(id, field.label); + + if ( + field.dataType === EntityDatatype.dataType || + field.dataType === EntityArrayDatatype.dataType + ) { + columnLabels.set(id + "_readable", field.label + " (readable)"); + } + } + + const exportEntities = await Promise.all( + data.map((item) => this.mapEntityToExportRow(item, columnLabels)), + ); const columnKeys: string[] = Array.from(columnLabels.keys()); const labels: any[] = Array.from(columnLabels.values()); @@ -149,4 +159,40 @@ export class DownloadService { }, ); } + + private async mapEntityToExportRow( + item: Entity, + columnLabels: Map, + ): Promise { + const newItem = {}; + for (const key in item) { + if (columnLabels.has(key)) { + newItem[key] = item[key]; + } + + if (columnLabels.has(key + "_readable")) { + newItem[key + "_readable"] = await this.loadRelatedEntitiesToString( + item[key], + ); + } + } + return newItem; + } + + private async loadRelatedEntitiesToString(value: string | string[]) { + const relatedEntitiesToStrings: string[] = []; + + const relatedEntitiesIds: string[] = Array.isArray(value) ? value : [value]; + for (const relatedEntityId of relatedEntitiesIds) { + relatedEntitiesToStrings.push( + ( + await this.entityMapperService + .load(Entity.extractTypeFromId(relatedEntityId), relatedEntityId) + .catch((e) => "") + ).toString(), + ); + } + + return relatedEntitiesToStrings; + } } diff --git a/src/app/core/form-dialog/dialog-buttons/dialog-buttons.component.html b/src/app/core/form-dialog/dialog-buttons/dialog-buttons.component.html index 19d8cabcae..61f3fa4c88 100644 --- a/src/app/core/form-dialog/dialog-buttons/dialog-buttons.component.html +++ b/src/app/core/form-dialog/dialog-buttons/dialog-buttons.component.html @@ -12,8 +12,9 @@